karthik.dev
Back to blogSaaS

NEXORA: Building a Multi-Role SaaS Platform with OpenAI and 80%+ Test Coverage

2026-06-12 · 5 min read

What NEXORA Is

NEXORA is a multi-utility SaaS platform that demonstrates what I consider the baseline quality bar for any production web application:

  • Authentication: JWT with refresh tokens, not just access tokens
  • Authorization: Role-based access control, not "is admin" booleans
  • AI Integration: OpenAI API with proper error handling, not raw fetch calls
  • Observability: Real-time usage analytics for administrators
  • Testing: 80%+ unit test coverage with mocked external dependencies
  • CI/CD: Automated pipeline from push to deployment

Authentication: JWT Done Right

A common mistake is issuing only access tokens. When they expire (typically after 15 minutes), the user is logged out. The fix: separate access tokens (short-lived) and refresh tokens (long-lived, stored in httpOnly cookies):

// src/auth/tokens.ts
export function issueTokenPair(userId: string, role: UserRole) {
  const accessToken = jwt.sign(
    { userId, role },
    process.env.ACCESS_TOKEN_SECRET!,
    { expiresIn: "15m" },
  );
  
  const refreshToken = jwt.sign(
    { userId },
    process.env.REFRESH_TOKEN_SECRET!,
    { expiresIn: "30d" },
  );
  
  return { accessToken, refreshToken };
}

// Refresh endpoint
export async function POST(req: Request) {
  const refreshToken = req.cookies.get("refresh_token")?.value;
  if (!refreshToken) return unauthorized();
  
  const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!);
  const user = await db.user.findUnique({ where: { id: payload.userId } });
  
  if (!user || user.refreshToken !== refreshToken) return unauthorized();
  
  const { accessToken, refreshToken: newRefreshToken } = issueTokenPair(user.id, user.role);
  
  // Rotate refresh token on each use (prevents refresh token theft)
  await db.user.update({ where: { id: user.id }, data: { refreshToken: newRefreshToken } });
  
  const response = NextResponse.json({ accessToken });
  response.cookies.set("refresh_token", newRefreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    maxAge: 30 * 24 * 60 * 60,
  });
  
  return response;
}

Refresh token rotation is critical: each use of a refresh token issues a new one. If a stolen token is used, the legitimate user's next request will fail (their token was already rotated) — alerting the system.

RBAC Implementation

// src/middleware/rbac.ts
const ROUTE_PERMISSIONS: Record<string, UserRole[]> = {
  "/api/admin/*": ["administrator"],
  "/api/analytics/*": ["administrator", "manager"],
  "/api/content/generate": ["user", "manager", "administrator"],
  "/api/users/*": ["administrator"],
};

export function withRBAC(handler: NextApiHandler, allowedRoles: UserRole[]) {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    const session = await getSession(req);
    
    if (!session || !allowedRoles.includes(session.role)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    
    return handler(req, res);
  };
}

OpenAI Integration with Proper Error Handling

Raw OpenAI API calls fail in production. Rate limits, timeouts, and content policy violations are all real failure modes that need explicit handling:

// src/services/ai-generation.ts
export async function generateContent(
  prompt: string,
  userId: string,
): Promise<GenerationResult> {
  // Rate limit check before calling API
  const usage = await getHourlyUsage(userId);
  if (usage >= 20) {
    throw new RateLimitError("You've reached the hourly generation limit.");
  }
  
  try {
    const response = await openai.chat.completions.create({
      model: "gpt-4o-mini",
      messages: [{ role: "user", content: prompt }],
      max_tokens: 1000,
    });
    
    const content = response.choices[0].message.content!;
    
    // Log usage for analytics
    await db.usageEvent.create({
      data: {
        userId,
        type: "content_generation",
        tokensUsed: response.usage?.total_tokens,
        timestamp: new Date(),
      },
    });
    
    return { content, tokensUsed: response.usage?.total_tokens };
    
  } catch (error) {
    if (error instanceof OpenAI.APIError) {
      if (error.status === 429) throw new RateLimitError("OpenAI rate limit hit.");
      if (error.status === 400) throw new ContentPolicyError("Content policy violation.");
    }
    throw error;
  }
}

Testing with Mocked External Services

The key to 80%+ coverage on AI-integrated code: mock the OpenAI client at the service layer, not the HTTP layer.

// src/services/__tests__/ai-generation.test.ts
jest.mock("openai", () => ({
  default: jest.fn().mockImplementation(() => ({
    chat: {
      completions: {
        create: jest.fn(),
      },
    },
  })),
}));

describe("generateContent", () => {
  let mockCreate: jest.Mock;
  
  beforeEach(() => {
    const OpenAI = require("openai").default;
    mockCreate = new OpenAI().chat.completions.create;
  });
  
  it("returns generated content on success", async () => {
    mockCreate.mockResolvedValueOnce({
      choices: [{ message: { content: "Generated text here" } }],
      usage: { total_tokens: 150 },
    });
    
    const result = await generateContent("Test prompt", "user-123");
    expect(result.content).toBe("Generated text here");
    expect(result.tokensUsed).toBe(150);
  });
  
  it("throws RateLimitError when user limit exceeded", async () => {
    await setHourlyUsage("user-123", 20); // mock at limit
    await expect(generateContent("Test prompt", "user-123"))
      .rejects.toThrow(RateLimitError);
    expect(mockCreate).not.toHaveBeenCalled();
  });
  
  it("throws RateLimitError on 429 from OpenAI", async () => {
    mockCreate.mockRejectedValueOnce(new OpenAI.APIError(429, {}, "Rate limited", {}));
    await expect(generateContent("Test prompt", "user-123"))
      .rejects.toThrow(RateLimitError);
  });
});

CI/CD Pipeline

# .github/workflows/ci.yml
name: CI/CD Pipeline

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
      - run: npm ci
      - run: npm run lint
      - run: npm run test -- --coverage --coverageThreshold='{"global":{"lines":80}}'
      
  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build Docker image
        run: docker build -t nexora:${{ github.sha }} .
      - name: Push to registry
        run: docker push ${{ env.REGISTRY }}/nexora:${{ github.sha }}
        
  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        run: ssh ${{ secrets.DEPLOY_HOST }} "docker pull ... && docker compose up -d"

The coverageThreshold flag in Jest fails the CI pipeline if coverage drops below 80%. This is the enforcement mechanism — without it, coverage goals are aspirational rather than enforced.

GitHub: github.com/karthikrshet/Nexora