test: add comprehensive unit tests for all API services and guards
Add 11 new test files covering roles guard, brand-access guard, users, brands, plans, payments, vehicles, categories, parts, referrals, and admin services. Total test count increases from ~52 to 164, all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
274
apps/api/src/admin/admin.service.spec.ts
Normal file
274
apps/api/src/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { AdminService } from "./admin.service";
|
||||||
|
|
||||||
|
describe("AdminService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDashboardStats", () => {
|
||||||
|
it("should return all 6 stats", async () => {
|
||||||
|
// getDashboardStats calls Promise.all with 6 db.select() calls:
|
||||||
|
// 1: select({count}).from(users) — from is terminal
|
||||||
|
// 2-6: select({count/total}).from(table).where(...) — where is terminal
|
||||||
|
//
|
||||||
|
// Trick: from() returns an array that also has a .where() method
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockImplementation(() => {
|
||||||
|
const result = [{ count: 10, total: 5000 }] as any;
|
||||||
|
result.where = vi.fn().mockReturnValue([{ count: 10, total: 5000 }]);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new AdminService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getDashboardStats();
|
||||||
|
expect(result).toHaveProperty("totalUsers");
|
||||||
|
expect(result).toHaveProperty("activeSubscriptions");
|
||||||
|
expect(result).toHaveProperty("totalRevenue");
|
||||||
|
expect(result).toHaveProperty("totalQueries");
|
||||||
|
expect(result).toHaveProperty("newUsersThisMonth");
|
||||||
|
expect(result).toHaveProperty("pendingPayments");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUsers", () => {
|
||||||
|
it("should return paginated users without search", async () => {
|
||||||
|
const items = [{ id: "u1", name: "Ali", email: "ali@test.com" }];
|
||||||
|
|
||||||
|
// getUsers: Promise.all([
|
||||||
|
// select().from().where().orderBy().limit().offset() — offset terminal
|
||||||
|
// select({count}).from().where() — where terminal
|
||||||
|
// ]), then optionally select().from().where() for subscriptions
|
||||||
|
let callCount = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const captured = callCount;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => {
|
||||||
|
// Count query (2nd): where is terminal
|
||||||
|
if (captured === 2) return [{ count: 1 }];
|
||||||
|
// Subscriptions query (3rd): where is terminal
|
||||||
|
if (captured === 3) return [];
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
c.orderBy = vi.fn().mockReturnValue(c);
|
||||||
|
c.limit = vi.fn().mockReturnValue(c);
|
||||||
|
c.offset = vi.fn().mockReturnValue(items);
|
||||||
|
c.innerJoin = vi.fn().mockReturnValue(c);
|
||||||
|
c.inArray = vi.fn().mockReturnValue(c);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new AdminService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getUsers(undefined, 1, 20);
|
||||||
|
expect(result.items).toBeDefined();
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.page).toBe(1);
|
||||||
|
expect(result.limit).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter users with search", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const captured = callCount;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [{ count: 0 }];
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
c.orderBy = vi.fn().mockReturnValue(c);
|
||||||
|
c.limit = vi.fn().mockReturnValue(c);
|
||||||
|
c.offset = vi.fn().mockReturnValue([]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new AdminService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getUsers("ali", 1, 20);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.page).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle pagination correctly", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const captured = callCount;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [{ count: 50 }];
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
c.orderBy = vi.fn().mockReturnValue(c);
|
||||||
|
c.limit = vi.fn().mockReturnValue(c);
|
||||||
|
c.offset = vi.fn().mockReturnValue([]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new AdminService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getUsers(undefined, 3, 10);
|
||||||
|
expect(result.page).toBe(3);
|
||||||
|
expect(result.limit).toBe(10);
|
||||||
|
expect(result.totalPages).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUserDetail", () => {
|
||||||
|
it("should return user with subscriptions and payments", async () => {
|
||||||
|
const user = { id: "u1", name: "Ali" };
|
||||||
|
|
||||||
|
// getUserDetail:
|
||||||
|
// 1: select().from(users).where().limit(1) — limit terminal
|
||||||
|
// 2: select().from(userSubscriptions).where().orderBy() — orderBy terminal
|
||||||
|
// 3: select().from(payments).where().orderBy() — orderBy terminal
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockReturnValue(c);
|
||||||
|
c.limit = vi.fn().mockReturnValue([user]);
|
||||||
|
c.orderBy = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [{ id: "sub-1" }];
|
||||||
|
return [{ id: "pay-1" }];
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new AdminService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getUserDetail("u1");
|
||||||
|
expect(result.id).toBe("u1");
|
||||||
|
expect(result).toHaveProperty("subscriptions");
|
||||||
|
expect(result).toHaveProperty("payments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when user not found", async () => {
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockReturnValue(c);
|
||||||
|
c.limit = vi.fn().mockReturnValue([]);
|
||||||
|
const db = { select: vi.fn().mockReturnValue(c) };
|
||||||
|
const service = new AdminService(db as any);
|
||||||
|
|
||||||
|
await expect(service.getUserDetail("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPendingPayments", () => {
|
||||||
|
it("should return pending EFT payments with user info", async () => {
|
||||||
|
const pending = [{ id: "pay-1", userId: "u1", userName: "Ali", method: "eft", status: "pending" }];
|
||||||
|
// select().from().innerJoin().where().orderBy() — orderBy terminal
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.innerJoin = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockReturnValue(c);
|
||||||
|
c.orderBy = vi.fn().mockReturnValue(pending);
|
||||||
|
const db = { select: vi.fn().mockReturnValue(c) };
|
||||||
|
const service = new AdminService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getPendingPayments();
|
||||||
|
expect(result).toEqual(pending);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getQueryLogs", () => {
|
||||||
|
it("should return paginated query logs without filter", async () => {
|
||||||
|
const logs = [{ id: "log-1", vin: "VIN1" }];
|
||||||
|
|
||||||
|
// getQueryLogs: Promise.all([
|
||||||
|
// select().from().innerJoin().leftJoin().where().orderBy().limit().offset() — offset terminal
|
||||||
|
// select({count}).from().where() — where terminal
|
||||||
|
// ])
|
||||||
|
let callCount = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const captured = callCount;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.innerJoin = vi.fn().mockReturnValue(c);
|
||||||
|
c.leftJoin = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [{ count: 1 }]; // terminal for count
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
c.orderBy = vi.fn().mockReturnValue(c);
|
||||||
|
c.limit = vi.fn().mockReturnValue(c);
|
||||||
|
c.offset = vi.fn().mockReturnValue(logs);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new AdminService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getQueryLogs(1, 50);
|
||||||
|
expect(result.items).toEqual(logs);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should filter query logs by userId", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const captured = callCount;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.innerJoin = vi.fn().mockReturnValue(c);
|
||||||
|
c.leftJoin = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [{ count: 0 }];
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
c.orderBy = vi.fn().mockReturnValue(c);
|
||||||
|
c.limit = vi.fn().mockReturnValue(c);
|
||||||
|
c.offset = vi.fn().mockReturnValue([]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new AdminService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getQueryLogs(1, 50, "u1");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.page).toBe(1);
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDailyStats", () => {
|
||||||
|
it("should return aggregated daily stats", async () => {
|
||||||
|
const stats = [
|
||||||
|
{ date: "2025-01-01", count: 10, successCount: 8, failureCount: 2 },
|
||||||
|
{ date: "2025-01-02", count: 15, successCount: 14, failureCount: 1 },
|
||||||
|
];
|
||||||
|
// select().from().where().groupBy().orderBy() — orderBy terminal
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockReturnValue(c);
|
||||||
|
c.groupBy = vi.fn().mockReturnValue(c);
|
||||||
|
c.orderBy = vi.fn().mockReturnValue(stats);
|
||||||
|
const db = { select: vi.fn().mockReturnValue(c) };
|
||||||
|
const service = new AdminService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getDailyStats();
|
||||||
|
expect(result).toEqual(stats);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
124
apps/api/src/brands/brands.service.spec.ts
Normal file
124
apps/api/src/brands/brands.service.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { BrandsService } from "./brands.service";
|
||||||
|
|
||||||
|
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||||
|
function chainable(terminalValue: unknown) {
|
||||||
|
const chain: Record<string, unknown> = {};
|
||||||
|
const methods = [
|
||||||
|
"select", "from", "where", "orderBy", "limit", "offset",
|
||||||
|
"insert", "values", "update", "set", "returning",
|
||||||
|
];
|
||||||
|
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
chain.returning = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
chain.orderBy = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: vi.fn().mockImplementation(() => chainable(overrides._selectRows ?? [])),
|
||||||
|
insert: vi.fn().mockImplementation(() => chainable(overrides._insertRows ?? [])),
|
||||||
|
update: vi.fn().mockImplementation(() => chainable(overrides._updateRows ?? [])),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BrandsService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should return active brands only by default", async () => {
|
||||||
|
const brands = [{ id: "b1", name: "BMW", isActive: true }];
|
||||||
|
const db = createMockDb({ _selectRows: brands });
|
||||||
|
const service = new BrandsService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findAll();
|
||||||
|
expect(result).toEqual(brands);
|
||||||
|
expect(db.select).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all brands when activeOnly is false", async () => {
|
||||||
|
const brands = [{ id: "b1", name: "BMW" }, { id: "b2", name: "Audi" }];
|
||||||
|
// When activeOnly=false, chain is: select().from().orderBy() — no where
|
||||||
|
const chain: Record<string, any> = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
orderBy: vi.fn().mockReturnValue(brands),
|
||||||
|
};
|
||||||
|
const db = { select: vi.fn().mockReturnValue(chain) };
|
||||||
|
const service = new BrandsService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findAll(false);
|
||||||
|
expect(result).toEqual(brands);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findById", () => {
|
||||||
|
it("should return brand when found", async () => {
|
||||||
|
const brand = { id: "b1", name: "BMW" };
|
||||||
|
const db = createMockDb({ _selectRows: [brand] });
|
||||||
|
const service = new BrandsService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findById("b1");
|
||||||
|
expect(result).toEqual(brand);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when not found", async () => {
|
||||||
|
const db = createMockDb({ _selectRows: [] });
|
||||||
|
const service = new BrandsService(db as any);
|
||||||
|
|
||||||
|
await expect(service.findById("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findBySlug", () => {
|
||||||
|
it("should return brand when found", async () => {
|
||||||
|
const brand = { id: "b1", slug: "bmw" };
|
||||||
|
const db = createMockDb({ _selectRows: [brand] });
|
||||||
|
const service = new BrandsService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findBySlug("bmw");
|
||||||
|
expect(result).toEqual(brand);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when not found", async () => {
|
||||||
|
const db = createMockDb({ _selectRows: [] });
|
||||||
|
const service = new BrandsService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findBySlug("nonexistent");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should create and return brand", async () => {
|
||||||
|
const newBrand = { id: "b1", name: "BMW", slug: "bmw" };
|
||||||
|
const db = createMockDb({ _insertRows: [newBrand] });
|
||||||
|
const service = new BrandsService(db as any);
|
||||||
|
|
||||||
|
const result = await service.create({ name: "BMW", slug: "bmw" });
|
||||||
|
expect(result).toEqual(newBrand);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
it("should update and return brand", async () => {
|
||||||
|
const updated = { id: "b1", name: "BMW Updated" };
|
||||||
|
const db = createMockDb({ _updateRows: [updated] });
|
||||||
|
const service = new BrandsService(db as any);
|
||||||
|
|
||||||
|
const result = await service.update("b1", { name: "BMW Updated" });
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when brand not found", async () => {
|
||||||
|
const db = createMockDb({ _updateRows: [] });
|
||||||
|
const service = new BrandsService(db as any);
|
||||||
|
|
||||||
|
await expect(service.update("nonexistent", { name: "X" })).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
157
apps/api/src/categories/categories.service.spec.ts
Normal file
157
apps/api/src/categories/categories.service.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { CategoriesService } from "./categories.service";
|
||||||
|
|
||||||
|
function createService(db: any) {
|
||||||
|
const redis = {
|
||||||
|
getJson: vi.fn().mockResolvedValue(null),
|
||||||
|
setJson: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const pl24Service = {
|
||||||
|
getCategories: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const service = new CategoriesService(db as any, redis as any, pl24Service as any);
|
||||||
|
return { service, db, redis, pl24Service };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chainable mock where limit is terminal */
|
||||||
|
function chain(limitValue: unknown) {
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockReturnValue(c);
|
||||||
|
c.orderBy = vi.fn().mockReturnValue(c);
|
||||||
|
c.limit = vi.fn().mockReturnValue(limitValue);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CategoriesService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCategoryTree", () => {
|
||||||
|
it("should return cached tree if available", async () => {
|
||||||
|
const cachedTree = [{ id: "c1", name: "Engine", children: [] }];
|
||||||
|
const db = { select: vi.fn() };
|
||||||
|
const { service, redis } = createService(db);
|
||||||
|
redis.getJson.mockResolvedValue(cachedTree);
|
||||||
|
|
||||||
|
const result = await service.getCategoryTree("v1");
|
||||||
|
expect(result).toEqual(cachedTree);
|
||||||
|
expect(redis.getJson).toHaveBeenCalledWith("cat:tree:v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return categories from DB when cache miss but DB has data", async () => {
|
||||||
|
const vehicle = { id: "v1", rawData: { vehicleId: "pl24-v1" }, brandName: "BMW" };
|
||||||
|
const dbCategories = [
|
||||||
|
{ id: "c1", name: "Engine", parentId: null, vehicleId: "v1" },
|
||||||
|
{ id: "c2", name: "Oil Filter", parentId: "c1", vehicleId: "v1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// getCategoryTree:
|
||||||
|
// 1: select().from(vehicles).where().limit(1) — where→limit
|
||||||
|
// 2: select().from(categories).where() — where is terminal
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return dbCategories; // terminal for categories
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
c.limit = vi.fn().mockReturnValue([vehicle]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service, redis } = createService(db);
|
||||||
|
const result = await service.getCategoryTree("v1");
|
||||||
|
expect(result).toBeInstanceOf(Array);
|
||||||
|
expect(result.length).toBe(1); // root node
|
||||||
|
expect(result[0].children.length).toBe(1); // child node
|
||||||
|
expect(redis.setJson).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch from PL24 when DB has no categories", async () => {
|
||||||
|
const vehicle = { id: "v1", rawData: { vehicleId: "pl24-v1" }, brandName: "BMW" };
|
||||||
|
const pl24Cats = [{ name: "Engine", groupId: "g1" }];
|
||||||
|
const insertedCats = [{ id: "c1", name: "Engine", parentId: null, vehicleId: "v1" }];
|
||||||
|
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return []; // no categories in DB (terminal)
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
c.limit = vi.fn().mockReturnValue([vehicle]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue(insertedCats),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service, pl24Service, redis } = createService(db);
|
||||||
|
pl24Service.getCategories.mockResolvedValue(pl24Cats);
|
||||||
|
|
||||||
|
const result = await service.getCategoryTree("v1");
|
||||||
|
expect(pl24Service.getCategories).toHaveBeenCalledWith("pl24-v1", "BMW");
|
||||||
|
expect(result).toBeInstanceOf(Array);
|
||||||
|
expect(redis.setJson).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when vehicle not found", async () => {
|
||||||
|
// select().from(vehicles).where().limit(1) → []
|
||||||
|
const db = { select: vi.fn().mockReturnValue(chain([])) };
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
await expect(service.getCategoryTree("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getById", () => {
|
||||||
|
it("should return category with schema pics", async () => {
|
||||||
|
const category = { id: "c1", name: "Engine" };
|
||||||
|
const pics = [{ id: "p1", categoryId: "c1", url: "https://pic.test/1.png" }];
|
||||||
|
|
||||||
|
// getById:
|
||||||
|
// 1: select().from(categories).where().limit(1) — limit is terminal
|
||||||
|
// 2: select().from(schemaPics).where() — where is terminal
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const c: Record<string, any> = {};
|
||||||
|
c.from = vi.fn().mockReturnValue(c);
|
||||||
|
c.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return pics; // terminal for schemaPics
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
c.limit = vi.fn().mockReturnValue([category]);
|
||||||
|
return c;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service } = createService(db);
|
||||||
|
const result = await service.getById("c1");
|
||||||
|
expect(result).toEqual({ ...category, schemaPics: pics });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when category not found", async () => {
|
||||||
|
const db = { select: vi.fn().mockReturnValue(chain([])) };
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
await expect(service.getById("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
156
apps/api/src/common/guards/brand-access.guard.spec.ts
Normal file
156
apps/api/src/common/guards/brand-access.guard.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { ForbiddenException } from "@nestjs/common";
|
||||||
|
import { BrandAccessGuard } from "./brand-access.guard";
|
||||||
|
|
||||||
|
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||||
|
function chainable(terminalValue: unknown) {
|
||||||
|
const chain: Record<string, unknown> = {};
|
||||||
|
const methods = ["select", "from", "where", "orderBy", "limit", "offset", "innerJoin", "insert", "values", "update", "set", "returning"];
|
||||||
|
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: vi.fn().mockImplementation(() => chainable(overrides._selectRows ?? [])),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockContext(options: { user?: any; params?: any; body?: any }) {
|
||||||
|
const request: Record<string, unknown> = {
|
||||||
|
params: options.params ?? {},
|
||||||
|
body: options.body ?? {},
|
||||||
|
};
|
||||||
|
if (options.user) request.user = options.user;
|
||||||
|
|
||||||
|
return {
|
||||||
|
switchToHttp: vi.fn().mockReturnValue({
|
||||||
|
getRequest: vi.fn().mockReturnValue(request),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BrandAccessGuard", () => {
|
||||||
|
it("should throw ForbiddenException when no user", async () => {
|
||||||
|
const db = createMockDb();
|
||||||
|
const guard = new BrandAccessGuard(db as any);
|
||||||
|
const context = createMockContext({});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context as any)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow admin users (bypass)", async () => {
|
||||||
|
const db = createMockDb();
|
||||||
|
const guard = new BrandAccessGuard(db as any);
|
||||||
|
const context = createMockContext({
|
||||||
|
user: { id: "u1", role: "admin" },
|
||||||
|
params: { brandId: "brand-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context as any);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow when no brandId in request", async () => {
|
||||||
|
const db = createMockDb();
|
||||||
|
const guard = new BrandAccessGuard(db as any);
|
||||||
|
const context = createMockContext({
|
||||||
|
user: { id: "u1", role: "user" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context as any);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException when no active subscription", async () => {
|
||||||
|
const db = createMockDb({ _selectRows: [] });
|
||||||
|
const guard = new BrandAccessGuard(db as any);
|
||||||
|
const context = createMockContext({
|
||||||
|
user: { id: "u1", role: "user" },
|
||||||
|
params: { brandId: "brand-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context as any)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException when active sub but no brand access", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const chain: Record<string, any> = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockImplementation(() => {
|
||||||
|
if (callCount === 1) return [{ id: "sub-1", userId: "u1", status: "active" }];
|
||||||
|
return []; // no brand access
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const guard = new BrandAccessGuard(db as any);
|
||||||
|
const context = createMockContext({
|
||||||
|
user: { id: "u1", role: "user" },
|
||||||
|
params: { brandId: "brand-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context as any)).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow when active sub + brand access", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const chain: Record<string, any> = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockImplementation(() => {
|
||||||
|
if (callCount === 1) return [{ id: "sub-1", userId: "u1", status: "active" }];
|
||||||
|
return [{ id: "ba-1", userId: "u1", brandId: "brand-1" }];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const guard = new BrandAccessGuard(db as any);
|
||||||
|
const context = createMockContext({
|
||||||
|
user: { id: "u1", role: "user" },
|
||||||
|
params: { brandId: "brand-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context as any);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should read brandId from body when not in params", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const chain: Record<string, any> = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockImplementation(() => {
|
||||||
|
if (callCount === 1) return [{ id: "sub-1", userId: "u1", status: "active" }];
|
||||||
|
return [{ id: "ba-1", userId: "u1", brandId: "brand-body" }];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const guard = new BrandAccessGuard(db as any);
|
||||||
|
const context = createMockContext({
|
||||||
|
user: { id: "u1", role: "user" },
|
||||||
|
body: { brandId: "brand-body" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await guard.canActivate(context as any);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
apps/api/src/common/guards/roles.guard.spec.ts
Normal file
70
apps/api/src/common/guards/roles.guard.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { ForbiddenException } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { RolesGuard } from "./roles.guard";
|
||||||
|
|
||||||
|
function createMockExecutionContext(user?: { role: string }) {
|
||||||
|
const request: Record<string, unknown> = {};
|
||||||
|
if (user) request.user = user;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getHandler: vi.fn(),
|
||||||
|
getClass: vi.fn(),
|
||||||
|
switchToHttp: vi.fn().mockReturnValue({
|
||||||
|
getRequest: vi.fn().mockReturnValue(request),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RolesGuard", () => {
|
||||||
|
let guard: RolesGuard;
|
||||||
|
let reflector: Reflector;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
reflector = { getAllAndOverride: vi.fn() } as unknown as Reflector;
|
||||||
|
guard = new RolesGuard(reflector);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow access when no roles are required", () => {
|
||||||
|
vi.mocked(reflector.getAllAndOverride).mockReturnValue(undefined);
|
||||||
|
const context = createMockExecutionContext({ role: "user" });
|
||||||
|
|
||||||
|
expect(guard.canActivate(context as any)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow access when roles array is empty", () => {
|
||||||
|
vi.mocked(reflector.getAllAndOverride).mockReturnValue([]);
|
||||||
|
const context = createMockExecutionContext({ role: "user" });
|
||||||
|
|
||||||
|
expect(guard.canActivate(context as any)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow access when user has matching role", () => {
|
||||||
|
vi.mocked(reflector.getAllAndOverride).mockReturnValue(["admin"]);
|
||||||
|
const context = createMockExecutionContext({ role: "admin" });
|
||||||
|
|
||||||
|
expect(guard.canActivate(context as any)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException when user has non-matching role", () => {
|
||||||
|
vi.mocked(reflector.getAllAndOverride).mockReturnValue(["admin"]);
|
||||||
|
const context = createMockExecutionContext({ role: "user" });
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(context as any)).toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException when no user on request", () => {
|
||||||
|
vi.mocked(reflector.getAllAndOverride).mockReturnValue(["admin"]);
|
||||||
|
const context = createMockExecutionContext();
|
||||||
|
|
||||||
|
expect(() => guard.canActivate(context as any)).toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow when user matches one of multiple required roles", () => {
|
||||||
|
vi.mocked(reflector.getAllAndOverride).mockReturnValue(["admin", "moderator"]);
|
||||||
|
const context = createMockExecutionContext({ role: "moderator" });
|
||||||
|
|
||||||
|
expect(guard.canActivate(context as any)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
162
apps/api/src/parts/parts.service.spec.ts
Normal file
162
apps/api/src/parts/parts.service.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { PartsService } from "./parts.service";
|
||||||
|
|
||||||
|
function createService(db: any) {
|
||||||
|
const pl24Service = {
|
||||||
|
getParts: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const service = new PartsService(db as any, pl24Service as any);
|
||||||
|
return { service, db, pl24Service };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a simple chainable mock where limit is the terminal */
|
||||||
|
function simpleChain(terminalValue: unknown) {
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.orderBy = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.innerJoin = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.leftJoin = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PartsService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getByCategory", () => {
|
||||||
|
it("should return parts from DB when cached", async () => {
|
||||||
|
const dbParts = [{ id: "p1", name: "Oil Filter", oemCode: "OEM-123" }];
|
||||||
|
// getByCategory: select().from(parts).where(...) — where is terminal here (no limit)
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnValue(dbParts),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.getByCategory("cat-1");
|
||||||
|
expect(result).toEqual(dbParts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch from PL24 when DB is empty", async () => {
|
||||||
|
const category = { id: "cat-1", vehicleId: "v1", externalId: "g1" };
|
||||||
|
const vehicle = { id: "v1", rawData: { vehicleId: "pl24-v1" }, brandName: "BMW" };
|
||||||
|
const pl24Parts = [{ name: "Oil Filter", oemCodes: ["OEM-1"], description: "Filter", quantity: 1, position: null, hotspotIndex: null }];
|
||||||
|
const insertedParts = [{ id: "p1", name: "Oil Filter", oemCode: "OEM-1" }];
|
||||||
|
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 1) return []; // no parts in DB (where is terminal)
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [category];
|
||||||
|
if (captured === 3) return [vehicle];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue(insertedParts),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service, pl24Service } = createService(db);
|
||||||
|
pl24Service.getParts.mockResolvedValue(pl24Parts);
|
||||||
|
|
||||||
|
const result = await service.getByCategory("cat-1");
|
||||||
|
expect(pl24Service.getParts).toHaveBeenCalledWith("pl24-v1", "g1", "BMW");
|
||||||
|
expect(result).toEqual(insertedParts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when category not found", async () => {
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 1) return []; // no parts
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockReturnValue([]); // no category
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service } = createService(db);
|
||||||
|
await expect(service.getByCategory("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when vehicle not found", async () => {
|
||||||
|
const category = { id: "cat-1", vehicleId: "v1", externalId: "g1" };
|
||||||
|
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 1) return []; // no parts
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [category];
|
||||||
|
return []; // vehicle not found
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service } = createService(db);
|
||||||
|
await expect(service.getByCategory("cat-1")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searchByOem", () => {
|
||||||
|
it("should return matching parts with vehicle info", async () => {
|
||||||
|
const results = [{ part: { id: "p1", oemCode: "OEM-123" }, vehicle: { vin: "VIN1" } }];
|
||||||
|
// searchByOem: select().from().innerJoin().where().limit(50)
|
||||||
|
const db = { select: vi.fn().mockReturnValue(simpleChain(results)) };
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.searchByOem("OEM-123");
|
||||||
|
expect(result).toEqual(results);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getById", () => {
|
||||||
|
it("should return part when found", async () => {
|
||||||
|
const part = { id: "p1", name: "Oil Filter" };
|
||||||
|
// getById: select().from().where().limit(1)
|
||||||
|
const db = { select: vi.fn().mockReturnValue(simpleChain([part])) };
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.getById("p1");
|
||||||
|
expect(result).toEqual(part);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when not found", async () => {
|
||||||
|
const db = { select: vi.fn().mockReturnValue(simpleChain([])) };
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
await expect(service.getById("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
296
apps/api/src/payments/payments.service.spec.ts
Normal file
296
apps/api/src/payments/payments.service.spec.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { PaymentsService } from "./payments.service";
|
||||||
|
|
||||||
|
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||||
|
function chainable(terminalValue: unknown) {
|
||||||
|
const chain: Record<string, unknown> = {};
|
||||||
|
const methods = [
|
||||||
|
"select", "from", "where", "orderBy", "limit", "offset",
|
||||||
|
"innerJoin", "leftJoin", "insert", "values", "update", "set",
|
||||||
|
"delete", "returning", "onConflictDoNothing", "groupBy",
|
||||||
|
];
|
||||||
|
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
chain.returning = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
chain.orderBy = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: vi.fn().mockImplementation(() => chainable(overrides._selectRows ?? [])),
|
||||||
|
insert: vi.fn().mockImplementation(() => chainable(overrides._insertRows ?? [])),
|
||||||
|
update: vi.fn().mockImplementation(() => chainable(overrides._updateRows ?? [])),
|
||||||
|
delete: vi.fn().mockImplementation(() => chainable(overrides._deleteRows ?? [])),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(dbOverrides: Record<string, unknown> = {}, subServiceOverrides: Record<string, any> = {}) {
|
||||||
|
const db = typeof dbOverrides.select === "function" ? dbOverrides : createMockDb(dbOverrides);
|
||||||
|
const configService = { get: vi.fn().mockReturnValue("test-value") };
|
||||||
|
const subscriptionsService = {
|
||||||
|
activateSubscription: vi.fn().mockResolvedValue(undefined),
|
||||||
|
create: vi.fn().mockResolvedValue({ id: "sub-1", planId: "plan-1", status: "pending" }),
|
||||||
|
addBrandsToSubscription: vi.fn().mockResolvedValue(undefined),
|
||||||
|
...subServiceOverrides,
|
||||||
|
};
|
||||||
|
const storageService = {
|
||||||
|
upload: vi.fn().mockResolvedValue("https://storage.test/receipt.pdf"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new PaymentsService(
|
||||||
|
db as any,
|
||||||
|
configService as any,
|
||||||
|
subscriptionsService as any,
|
||||||
|
storageService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, db, configService, subscriptionsService, storageService };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PaymentsService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initializeIyzico", () => {
|
||||||
|
it("should create subscription, payment and return paymentId", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "plan-1", brandCount: 1, priceMonthly: 20000, priceYearly: 200000, isActive: true }]),
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([{ id: "pay-1", status: "pending" }]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service, subscriptionsService } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.initializeIyzico("u1", "brand1", "monthly", ["brand-id-1"]);
|
||||||
|
expect(result.paymentId).toBe("pay-1");
|
||||||
|
expect(result.status).toBe("pending");
|
||||||
|
expect(subscriptionsService.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for invalid plan key", async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
await expect(service.initializeIyzico("u1", "invalid", "monthly", [])).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleIyzicoCallback", () => {
|
||||||
|
it("should activate subscription on success", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", status: "pending" }]),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service, subscriptionsService } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.handleIyzicoCallback("pay-1", "iyz-123", "success");
|
||||||
|
expect(result.status).toBe("completed");
|
||||||
|
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set failed status on failure callback", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", status: "pending" }]),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service, subscriptionsService } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.handleIyzicoCallback("pay-1", "iyz-123", "failure");
|
||||||
|
expect(result.status).toBe("failed");
|
||||||
|
expect(subscriptionsService.activateSubscription).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when payment not found", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
await expect(service.handleIyzicoCallback("nonexistent", "iyz-1", "success")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createEftPayment", () => {
|
||||||
|
it("should create subscription, EFT payment and return bank info", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "plan-1", brandCount: 0, priceMonthly: 99900, priceYearly: 999000, isActive: true }]),
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([{ id: "pay-eft-1", method: "eft", status: "pending" }]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service, subscriptionsService } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.createEftPayment("u1", "full", "yearly", []);
|
||||||
|
expect(result.paymentId).toBe("pay-eft-1");
|
||||||
|
expect(result.bankInfo).toBeDefined();
|
||||||
|
expect(result.bankInfo.bankName).toBe("Ziraat Bankası");
|
||||||
|
expect(subscriptionsService.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for invalid plan key", async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
await expect(service.createEftPayment("u1", "nonexistent", "monthly", [])).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uploadEftReceipt", () => {
|
||||||
|
it("should upload receipt and return url", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "pay-1", userId: "u1", method: "eft" }]),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.uploadEftReceipt("pay-1", "u1", Buffer.from("pdf"), "receipt.pdf");
|
||||||
|
expect(result.receiptUrl).toBe("https://storage.test/receipt.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when payment not found", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
await expect(service.uploadEftReceipt("pay-x", "u1", Buffer.from("pdf"), "r.pdf")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException when not EFT method", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "pay-1", userId: "u1", method: "iyzico" }]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
await expect(service.uploadEftReceipt("pay-1", "u1", Buffer.from("pdf"), "r.pdf")).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("approveEft", () => {
|
||||||
|
it("should approve and activate subscription", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "pay-1", subscriptionId: "sub-1", method: "eft" }]),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service, subscriptionsService } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.approveEft("pay-1", "Looks good");
|
||||||
|
expect(result.status).toBe("completed");
|
||||||
|
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when payment not found", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
await expect(service.approveEft("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException when not EFT method", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "pay-1", method: "iyzico" }]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
await expect(service.approveEft("pay-1")).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rejectEft", () => {
|
||||||
|
it("should reject and return failed status", async () => {
|
||||||
|
const db = {
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.rejectEft("pay-1", "Bad receipt");
|
||||||
|
expect(result.status).toBe("failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMyPayments", () => {
|
||||||
|
it("should return list of payments", async () => {
|
||||||
|
const paymentsList = [{ id: "pay-1" }, { id: "pay-2" }];
|
||||||
|
const db = createMockDb({ _selectRows: paymentsList });
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.getMyPayments("u1");
|
||||||
|
expect(result).toEqual(paymentsList);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPendingEftPayments", () => {
|
||||||
|
it("should return pending EFT payments", async () => {
|
||||||
|
const pending = [{ id: "pay-1", method: "eft", status: "pending" }];
|
||||||
|
const db = createMockDb({ _selectRows: pending });
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.getPendingEftPayments();
|
||||||
|
expect(result).toEqual(pending);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
apps/api/src/plans/plans.service.spec.ts
Normal file
102
apps/api/src/plans/plans.service.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { PlansService } from "./plans.service";
|
||||||
|
|
||||||
|
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||||
|
function chainable(terminalValue: unknown) {
|
||||||
|
const chain: Record<string, unknown> = {};
|
||||||
|
const methods = [
|
||||||
|
"select", "from", "where", "orderBy", "limit", "offset",
|
||||||
|
"insert", "values", "update", "set", "returning",
|
||||||
|
];
|
||||||
|
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
chain.returning = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
chain.orderBy = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: vi.fn().mockImplementation(() => chainable(overrides._selectRows ?? [])),
|
||||||
|
insert: vi.fn().mockImplementation(() => chainable(overrides._insertRows ?? [])),
|
||||||
|
update: vi.fn().mockImplementation(() => chainable(overrides._updateRows ?? [])),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PlansService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should return active plans only by default", async () => {
|
||||||
|
const plans = [{ id: "p1", name: "1 Marka", isActive: true }];
|
||||||
|
const db = createMockDb({ _selectRows: plans });
|
||||||
|
const service = new PlansService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findAll();
|
||||||
|
expect(result).toEqual(plans);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return all plans when activeOnly is false", async () => {
|
||||||
|
const plans = [{ id: "p1" }, { id: "p2" }];
|
||||||
|
const chain: Record<string, any> = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
orderBy: vi.fn().mockReturnValue(plans),
|
||||||
|
};
|
||||||
|
const db = { select: vi.fn().mockReturnValue(chain) };
|
||||||
|
const service = new PlansService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findAll(false);
|
||||||
|
expect(result).toEqual(plans);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findById", () => {
|
||||||
|
it("should return plan when found", async () => {
|
||||||
|
const plan = { id: "p1", name: "1 Marka" };
|
||||||
|
const db = createMockDb({ _selectRows: [plan] });
|
||||||
|
const service = new PlansService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findById("p1");
|
||||||
|
expect(result).toEqual(plan);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when not found", async () => {
|
||||||
|
const db = createMockDb({ _selectRows: [] });
|
||||||
|
const service = new PlansService(db as any);
|
||||||
|
|
||||||
|
await expect(service.findById("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should create and return plan", async () => {
|
||||||
|
const newPlan = { id: "p1", name: "1 Marka", brandCount: 1, priceMonthly: 20000, priceYearly: 200000 };
|
||||||
|
const db = createMockDb({ _insertRows: [newPlan] });
|
||||||
|
const service = new PlansService(db as any);
|
||||||
|
|
||||||
|
const result = await service.create({ name: "1 Marka", brandCount: 1, priceMonthly: 20000, priceYearly: 200000 });
|
||||||
|
expect(result).toEqual(newPlan);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
it("should update and return plan", async () => {
|
||||||
|
const updated = { id: "p1", name: "Updated Plan" };
|
||||||
|
const db = createMockDb({ _updateRows: [updated] });
|
||||||
|
const service = new PlansService(db as any);
|
||||||
|
|
||||||
|
const result = await service.update("p1", { name: "Updated Plan" });
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when plan not found", async () => {
|
||||||
|
const db = createMockDb({ _updateRows: [] });
|
||||||
|
const service = new PlansService(db as any);
|
||||||
|
|
||||||
|
await expect(service.update("nonexistent", { name: "X" })).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
332
apps/api/src/referrals/referrals.service.spec.ts
Normal file
332
apps/api/src/referrals/referrals.service.spec.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { ReferralsService } from "./referrals.service";
|
||||||
|
|
||||||
|
vi.mock("@sase/shared", () => ({
|
||||||
|
REFERRAL_REWARDS: {
|
||||||
|
TIER_1: { count: 3, extensionDays: 7 },
|
||||||
|
TIER_2: { count: 5, extensionDays: 30 },
|
||||||
|
},
|
||||||
|
generateReferralCode: vi.fn().mockReturnValue("REF-ABC123"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createService(db: any) {
|
||||||
|
const subscriptionsService = {
|
||||||
|
extendSubscription: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const service = new ReferralsService(db as any, subscriptionsService as any);
|
||||||
|
return { service, db, subscriptionsService };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ReferralsService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getStats", () => {
|
||||||
|
it("should throw NotFoundException when user not found", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
await expect(service.getStats("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 0 rewardDays when 0 referrals", async () => {
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [{ count: 0 }]; // terminal for count
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockReturnValue([{ id: "u1" }]);
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.getStats("u1");
|
||||||
|
expect(result.totalReferrals).toBe(0);
|
||||||
|
expect(result.rewardDays).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return tier 1 rewardDays when at threshold", async () => {
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [{ count: 3 }];
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockReturnValue([{ id: "u1" }]);
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.getStats("u1");
|
||||||
|
expect(result.totalReferrals).toBe(3);
|
||||||
|
expect(result.rewardDays).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return tier 2 rewardDays when at threshold", async () => {
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [{ count: 5 }];
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockReturnValue([{ id: "u1" }]);
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.getStats("u1");
|
||||||
|
expect(result.totalReferrals).toBe(5);
|
||||||
|
expect(result.rewardDays).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMyReferrals", () => {
|
||||||
|
it("should return referrals with code and total", async () => {
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [{ id: "r1" }, { id: "r2" }]; // terminal
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockReturnValue([{ id: "u1", referralCode: "REF-XYZ" }]);
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.getMyReferrals("u1");
|
||||||
|
expect(result.referralCode).toBe("REF-XYZ");
|
||||||
|
expect(result.totalReferrals).toBe(2);
|
||||||
|
expect(result.referrals).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when user not found", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
await expect(service.getMyReferrals("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyReferralCode", () => {
|
||||||
|
it("should throw NotFoundException for invalid code", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
await expect(service.applyReferralCode("u1", "INVALID")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for self-referral", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "u1", referralCode: "SELF-CODE" }]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
await expect(service.applyReferralCode("u1", "SELF-CODE")).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException when already referred", async () => {
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
return {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockImplementation(() => {
|
||||||
|
if (selectCall === 1) return [{ id: "u2", referralCode: "REF-U2" }];
|
||||||
|
if (selectCall === 2) return [{ id: "existing" }];
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
await expect(service.applyReferralCode("u1", "REF-U2")).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply referral code successfully", async () => {
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockImplementation(() => {
|
||||||
|
// Count query (3rd select) — where is terminal
|
||||||
|
if (captured === 3) return [{ count: 1 }];
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 1) return [{ id: "referrer-1", referralCode: "REF-CODE" }];
|
||||||
|
return []; // not already referred
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.applyReferralCode("u1", "REF-CODE");
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trigger tier 1 reward when count reaches threshold", async () => {
|
||||||
|
// applyReferralCode select calls:
|
||||||
|
// 1: find referrer by code (where→limit)
|
||||||
|
// 2: check existing referral (where→limit)
|
||||||
|
// then: insert + update (not select)
|
||||||
|
// 3: count referrals (where is terminal)
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 3) return [{ count: 3 }]; // tier 1
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 1) return [{ id: "referrer-1", referralCode: "REF-CODE" }];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service, subscriptionsService } = createService(db);
|
||||||
|
|
||||||
|
await service.applyReferralCode("u1", "REF-CODE");
|
||||||
|
expect(subscriptionsService.extendSubscription).toHaveBeenCalledWith("referrer-1", 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trigger tier 2 reward when count reaches threshold", async () => {
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
const captured = selectCall;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 3) return [{ count: 5 }]; // tier 2
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 1) return [{ id: "referrer-1", referralCode: "REF-CODE" }];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service, subscriptionsService } = createService(db);
|
||||||
|
|
||||||
|
await service.applyReferralCode("u1", "REF-CODE");
|
||||||
|
expect(subscriptionsService.extendSubscription).toHaveBeenCalledWith("referrer-1", 30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ensureReferralCode", () => {
|
||||||
|
it("should return existing code if user already has one", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "u1", referralCode: "EXISTING-CODE" }]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.ensureReferralCode("u1");
|
||||||
|
expect(result).toBe("EXISTING-CODE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate and save new code when user has none", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "u1", referralCode: null }]),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.ensureReferralCode("u1");
|
||||||
|
expect(result).toBe("REF-ABC123");
|
||||||
|
expect(db.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
251
apps/api/src/users/users.service.spec.ts
Normal file
251
apps/api/src/users/users.service.spec.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { UsersService } from "./users.service";
|
||||||
|
|
||||||
|
vi.mock("better-auth/crypto", () => ({
|
||||||
|
hashPassword: vi.fn().mockResolvedValue("hashed-new-password"),
|
||||||
|
verifyPassword: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { verifyPassword } from "better-auth/crypto";
|
||||||
|
|
||||||
|
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||||
|
function chainable(terminalValue: unknown) {
|
||||||
|
const chain: Record<string, unknown> = {};
|
||||||
|
const methods = [
|
||||||
|
"select", "from", "where", "orderBy", "limit", "offset",
|
||||||
|
"innerJoin", "insert", "values", "update", "set", "delete",
|
||||||
|
"returning", "onConflictDoNothing",
|
||||||
|
];
|
||||||
|
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
chain.returning = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
chain.offset = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: vi.fn().mockImplementation(() => chainable(overrides._selectRows ?? [])),
|
||||||
|
insert: vi.fn().mockImplementation(() => chainable(overrides._insertRows ?? [])),
|
||||||
|
update: vi.fn().mockImplementation(() => chainable(overrides._updateRows ?? [])),
|
||||||
|
delete: vi.fn().mockImplementation(() => chainable(overrides._deleteRows ?? [])),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("UsersService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findById", () => {
|
||||||
|
it("should return user when found", async () => {
|
||||||
|
const user = { id: "u1", name: "Ali", email: "ali@test.com" };
|
||||||
|
const db = createMockDb({ _selectRows: [user] });
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findById("u1");
|
||||||
|
expect(result).toEqual(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when not found", async () => {
|
||||||
|
const db = createMockDb({ _selectRows: [] });
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
await expect(service.findById("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findByEmail", () => {
|
||||||
|
it("should return user when found", async () => {
|
||||||
|
const user = { id: "u1", email: "ali@test.com" };
|
||||||
|
const db = createMockDb({ _selectRows: [user] });
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findByEmail("ali@test.com");
|
||||||
|
expect(result).toEqual(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when not found", async () => {
|
||||||
|
const db = createMockDb({ _selectRows: [] });
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findByEmail("nobody@test.com");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateProfile", () => {
|
||||||
|
it("should return updated user on success", async () => {
|
||||||
|
const updated = { id: "u1", name: "Updated" };
|
||||||
|
const db = createMockDb({ _updateRows: [updated] });
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
const result = await service.updateProfile("u1", { name: "Updated" });
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when user not found", async () => {
|
||||||
|
const db = createMockDb({ _updateRows: [] });
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
await expect(service.updateProfile("nonexistent", { name: "X" })).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getConnections", () => {
|
||||||
|
it("should return provider map with google true", async () => {
|
||||||
|
// getConnections: select({providerId}).from(accounts).where(...) — where is terminal
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnValue([{ providerId: "google" }]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getConnections("u1");
|
||||||
|
expect(result).toEqual({ google: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return provider map with google false when no google account", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
const result = await service.getConnections("u1");
|
||||||
|
expect(result).toEqual({ google: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlinkConnection", () => {
|
||||||
|
it("should return success when connection deleted", async () => {
|
||||||
|
const db = createMockDb({ _deleteRows: [{ id: "acc-1" }] });
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
const result = await service.unlinkConnection("u1", "google");
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when connection not found", async () => {
|
||||||
|
const db = createMockDb({ _deleteRows: [] });
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
await expect(service.unlinkConnection("u1", "google")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("changePassword", () => {
|
||||||
|
it("should return success when password changed", async () => {
|
||||||
|
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "acc-1", password: "old-hash", providerId: "credential" }]),
|
||||||
|
}),
|
||||||
|
update: vi.fn().mockReturnValue({
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
const result = await service.changePassword("u1", "oldpass", "newpassword");
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException when no credential account", async () => {
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
await expect(service.changePassword("u1", "old", "newpassword")).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException when current password is wrong", async () => {
|
||||||
|
vi.mocked(verifyPassword).mockResolvedValue(false);
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "acc-1", password: "hash", providerId: "credential" }]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
await expect(service.changePassword("u1", "wrongpass", "newpassword")).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException when new password is too short", async () => {
|
||||||
|
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([{ id: "acc-1", password: "hash", providerId: "credential" }]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
await expect(service.changePassword("u1", "oldpass", "short")).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteAccount", () => {
|
||||||
|
it("should return success when account deleted", async () => {
|
||||||
|
const db = createMockDb({ _deleteRows: [{ id: "u1" }] });
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
const result = await service.deleteAccount("u1");
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when user not found", async () => {
|
||||||
|
const db = createMockDb({ _deleteRows: [] });
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
await expect(service.deleteAccount("nonexistent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should return paginated items and total", async () => {
|
||||||
|
const items = [{ id: "u1" }, { id: "u2" }];
|
||||||
|
// findAll: Promise.all([
|
||||||
|
// db.select().from(users).limit(limit).offset(offset).orderBy(createdAt),
|
||||||
|
// db.select({count}).from(users),
|
||||||
|
// ])
|
||||||
|
let callCount = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
const captured = callCount;
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockImplementation(() => {
|
||||||
|
if (captured === 2) return [{ id: "c" }]; // from is terminal for count query
|
||||||
|
return chain;
|
||||||
|
});
|
||||||
|
chain.limit = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.offset = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.orderBy = vi.fn().mockReturnValue(items);
|
||||||
|
return chain;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const service = new UsersService(db as any);
|
||||||
|
|
||||||
|
const result = await service.findAll(1, 20);
|
||||||
|
expect(result.items).toEqual(items);
|
||||||
|
expect(result.total).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
346
apps/api/src/vehicles/vehicles.service.spec.ts
Normal file
346
apps/api/src/vehicles/vehicles.service.spec.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { BadRequestException, ForbiddenException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { VehiclesService } from "./vehicles.service";
|
||||||
|
|
||||||
|
vi.mock("@sase/shared", () => ({
|
||||||
|
isValidVin: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { isValidVin } from "@sase/shared";
|
||||||
|
|
||||||
|
function createMockDb(overrides: Record<string, unknown> = {}) {
|
||||||
|
function chainable(terminalValue: unknown) {
|
||||||
|
const chain: Record<string, unknown> = {};
|
||||||
|
const methods = [
|
||||||
|
"select", "from", "where", "orderBy", "limit", "offset",
|
||||||
|
"innerJoin", "leftJoin", "insert", "values", "update", "set",
|
||||||
|
"delete", "returning", "onConflictDoNothing", "groupBy",
|
||||||
|
];
|
||||||
|
for (const m of methods) chain[m] = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.limit = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
chain.returning = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
chain.offset = vi.fn().mockReturnValue(terminalValue);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: vi.fn().mockImplementation(() => chainable(overrides._selectRows ?? [])),
|
||||||
|
insert: vi.fn().mockImplementation(() => chainable(overrides._insertRows ?? [])),
|
||||||
|
update: vi.fn().mockImplementation(() => chainable(overrides._updateRows ?? [])),
|
||||||
|
delete: vi.fn().mockImplementation(() => chainable(overrides._deleteRows ?? [])),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(dbOrOverrides: any = {}) {
|
||||||
|
const db = typeof dbOrOverrides.select === "function" ? dbOrOverrides : createMockDb(dbOrOverrides);
|
||||||
|
const corgiService = {
|
||||||
|
decodeVin: vi.fn(),
|
||||||
|
};
|
||||||
|
const pl24Service = {
|
||||||
|
decodeVin: vi.fn(),
|
||||||
|
};
|
||||||
|
const vinApiService = {
|
||||||
|
decodeVin: vi.fn(),
|
||||||
|
};
|
||||||
|
const emexService = {
|
||||||
|
getScrapedVehicle: vi.fn(),
|
||||||
|
decodeVin: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new VehiclesService(
|
||||||
|
db as any,
|
||||||
|
corgiService as any,
|
||||||
|
pl24Service as any,
|
||||||
|
vinApiService as any,
|
||||||
|
emexService as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, db, corgiService, pl24Service, vinApiService, emexService };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VehiclesService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodeVin", () => {
|
||||||
|
it("should throw BadRequestException for invalid VIN", async () => {
|
||||||
|
vi.mocked(isValidVin).mockReturnValue(false);
|
||||||
|
const { service } = createService();
|
||||||
|
|
||||||
|
await expect(service.decodeVin("BADVIN", "u1")).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return cached vehicle if fresh (<24h)", async () => {
|
||||||
|
vi.mocked(isValidVin).mockReturnValue(true);
|
||||||
|
const cached = {
|
||||||
|
id: "v1",
|
||||||
|
vin: "WBAPH5C55BA123456",
|
||||||
|
userId: "u1",
|
||||||
|
brandId: "b1",
|
||||||
|
updatedAt: new Date(), // fresh
|
||||||
|
};
|
||||||
|
|
||||||
|
// db.select() calls:
|
||||||
|
// 1st: cache check (vehicles) → returns cached
|
||||||
|
// 2nd: logQuery insert (won't be called since we stub insert)
|
||||||
|
const selectChain = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([cached]),
|
||||||
|
};
|
||||||
|
const insertChain = {
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([]),
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue(selectChain),
|
||||||
|
insert: vi.fn().mockReturnValue(insertChain),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service } = createService(db);
|
||||||
|
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
||||||
|
expect(result).toEqual(cached);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException when corgi doesn't recognize VIN", async () => {
|
||||||
|
vi.mocked(isValidVin).mockReturnValue(true);
|
||||||
|
|
||||||
|
const selectChain = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([]), // no cache
|
||||||
|
};
|
||||||
|
const insertChain = {
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([]),
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue(selectChain),
|
||||||
|
insert: vi.fn().mockReturnValue(insertChain),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service, corgiService } = createService(db);
|
||||||
|
corgiService.decodeVin.mockReturnValue(null);
|
||||||
|
|
||||||
|
await expect(service.decodeVin("WBAPH5C55BA123456", "u1")).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException when brand not found in DB", async () => {
|
||||||
|
vi.mocked(isValidVin).mockReturnValue(true);
|
||||||
|
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
return {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockReturnValue([]), // no cache, no brand
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service, corgiService } = createService(db);
|
||||||
|
corgiService.decodeVin.mockReturnValue({ isKnown: true, brandName: "UnknownBrand", modelYear: 2020 });
|
||||||
|
|
||||||
|
await expect(service.decodeVin("WBAPH5C55BA123456", "u1")).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException when user has no active subscription", async () => {
|
||||||
|
vi.mocked(isValidVin).mockReturnValue(true);
|
||||||
|
|
||||||
|
let selectCall = 0;
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
return {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockImplementation(() => {
|
||||||
|
if (selectCall === 1) return []; // no cache
|
||||||
|
if (selectCall === 2) return [{ id: "b1", name: "BMW" }]; // brand found
|
||||||
|
if (selectCall === 3) return []; // no active subscription
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service, corgiService } = createService(db);
|
||||||
|
corgiService.decodeVin.mockReturnValue({ isKnown: true, brandName: "BMW", modelYear: 2020 });
|
||||||
|
|
||||||
|
await expect(service.decodeVin("WBAPH5C55BA123456", "u1")).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use PL24 when available and save vehicle", async () => {
|
||||||
|
vi.mocked(isValidVin).mockReturnValue(true);
|
||||||
|
|
||||||
|
let selectCall = 0;
|
||||||
|
const savedVehicle = { id: "v-new", vin: "WBAPH5C55BA123456", source: "pl24" };
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
return {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockImplementation(() => {
|
||||||
|
if (selectCall === 1) return []; // no cache
|
||||||
|
if (selectCall === 2) return [{ id: "b1", name: "BMW" }]; // brand
|
||||||
|
if (selectCall === 3) return [{ id: "sub-1", status: "active" }]; // subscription
|
||||||
|
if (selectCall === 4) return [{ id: "ba-1" }]; // brand access
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([savedVehicle]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service, corgiService, pl24Service } = createService(db);
|
||||||
|
corgiService.decodeVin.mockReturnValue({ isKnown: true, brandName: "BMW", modelYear: 2020 });
|
||||||
|
pl24Service.decodeVin.mockResolvedValue({ vehicleId: "pl24-v1", modelCode: "320i", yearFrom: 2020 });
|
||||||
|
|
||||||
|
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
||||||
|
expect(result).toEqual(savedVehicle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to emex when PL24 returns null", async () => {
|
||||||
|
vi.mocked(isValidVin).mockReturnValue(true);
|
||||||
|
|
||||||
|
let selectCall = 0;
|
||||||
|
const savedVehicle = { id: "v-emex", source: "emex" };
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
return {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockImplementation(() => {
|
||||||
|
if (selectCall === 1) return [];
|
||||||
|
if (selectCall === 2) return [{ id: "b1", name: "BMW" }];
|
||||||
|
if (selectCall === 3) return [{ id: "sub-1", status: "active" }];
|
||||||
|
if (selectCall === 4) return [{ id: "ba-1" }];
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([savedVehicle]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service, corgiService, pl24Service, emexService } = createService(db);
|
||||||
|
corgiService.decodeVin.mockReturnValue({ isKnown: true, brandName: "BMW", modelYear: 2020 });
|
||||||
|
pl24Service.decodeVin.mockResolvedValue(null);
|
||||||
|
emexService.getScrapedVehicle.mockResolvedValue({ modelCode: "320i", yearFrom: 2020, engine: "N20", rawData: {} });
|
||||||
|
|
||||||
|
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
||||||
|
expect(result).toEqual(savedVehicle);
|
||||||
|
expect(emexService.getScrapedVehicle).toHaveBeenCalledWith("WBAPH5C55BA123456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fallback to vinApi when PL24 and emex fail", async () => {
|
||||||
|
vi.mocked(isValidVin).mockReturnValue(true);
|
||||||
|
|
||||||
|
let selectCall = 0;
|
||||||
|
const savedVehicle = { id: "v-api", source: "vin-api" };
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockImplementation(() => {
|
||||||
|
selectCall++;
|
||||||
|
return {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockImplementation(() => {
|
||||||
|
if (selectCall === 1) return [];
|
||||||
|
if (selectCall === 2) return [{ id: "b1", name: "BMW" }];
|
||||||
|
if (selectCall === 3) return [{ id: "sub-1", status: "active" }];
|
||||||
|
if (selectCall === 4) return [{ id: "ba-1" }];
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
insert: vi.fn().mockReturnValue({
|
||||||
|
values: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockReturnValue([savedVehicle]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { service, corgiService, pl24Service, emexService, vinApiService } = createService(db);
|
||||||
|
corgiService.decodeVin.mockReturnValue({ isKnown: true, brandName: "BMW", modelYear: 2020 });
|
||||||
|
pl24Service.decodeVin.mockResolvedValue(null);
|
||||||
|
emexService.getScrapedVehicle.mockResolvedValue(null);
|
||||||
|
emexService.decodeVin.mockResolvedValue(undefined);
|
||||||
|
vinApiService.decodeVin.mockResolvedValue({ model: "320i", modelYear: "2020" });
|
||||||
|
|
||||||
|
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
|
||||||
|
expect(result).toEqual(savedVehicle);
|
||||||
|
expect(vinApiService.decodeVin).toHaveBeenCalledWith("WBAPH5C55BA123456");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getHistory", () => {
|
||||||
|
it("should return paginated vehicle list", async () => {
|
||||||
|
const vehicleList = [{ id: "v1" }, { id: "v2" }];
|
||||||
|
// Chain: select().from().where().orderBy().limit().offset()
|
||||||
|
const chain: Record<string, any> = {};
|
||||||
|
chain.from = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.where = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.orderBy = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.limit = vi.fn().mockReturnValue(chain);
|
||||||
|
chain.offset = vi.fn().mockReturnValue(vehicleList);
|
||||||
|
const db = { select: vi.fn().mockReturnValue(chain) };
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.getHistory("u1", 1, 20);
|
||||||
|
expect(result).toEqual(vehicleList);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getById", () => {
|
||||||
|
it("should return vehicle when found", async () => {
|
||||||
|
const vehicle = { id: "v1", userId: "u1", vin: "WBAPH5C55BA123456" };
|
||||||
|
const db = createMockDb({ _selectRows: [vehicle] });
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.getById("v1", "u1");
|
||||||
|
expect(result).toEqual(vehicle);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when not found", async () => {
|
||||||
|
const db = createMockDb({ _selectRows: [] });
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
await expect(service.getById("nonexistent", "u1")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteVehicle", () => {
|
||||||
|
it("should return deleted true on success", async () => {
|
||||||
|
const db = createMockDb({ _deleteRows: [{ id: "v1" }] });
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
const result = await service.deleteVehicle("v1", "u1");
|
||||||
|
expect(result).toEqual({ deleted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when not found", async () => {
|
||||||
|
const db = createMockDb({ _deleteRows: [] });
|
||||||
|
const { service } = createService(db);
|
||||||
|
|
||||||
|
await expect(service.deleteVehicle("nonexistent", "u1")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user