diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a418d7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - run: npm ci + + - run: npm run build + + - run: npm run test diff --git a/CHANGELOG.md b/CHANGELOG.md index c17f1f8..58dfa9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.13.1] (2026-03-08) + +### Added +- GitHub Actions CI workflow: runs build and tests on push/PR to main +- Login utility tests: 7 tests covering token caching, expiry, re-authentication, and profile fallback + ## [1.13.0] (2026-03-08) ### Added @@ -229,6 +235,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. Initial release. +[1.13.1]: https://github.com/tomasbruckner/sloneek-cli/compare/v1.13.0...v1.13.1 [1.13.0]: https://github.com/tomasbruckner/sloneek-cli/compare/v1.12.0...v1.13.0 [1.12.0]: https://github.com/tomasbruckner/sloneek-cli/compare/v1.11.0...v1.12.0 [1.11.0]: https://github.com/tomasbruckner/sloneek-cli/compare/v1.10.6...v1.11.0 diff --git a/package.json b/package.json index 6d7aa88..e556959 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sloneek-cli", - "version": "1.13.0", + "version": "1.13.1", "description": "Sloneek CLI for creating your worklogs 100x faster.", "main": "index.js", "author": "Tomas Bruckner", diff --git a/src/lib/utils/login.test.ts b/src/lib/utils/login.test.ts new file mode 100644 index 0000000..45844e0 --- /dev/null +++ b/src/lib/utils/login.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DateTime } from "luxon"; + +vi.mock("terminal-kit", () => ({ + terminal: { + cyan: vi.fn(), + green: vi.fn(), + }, +})); + +vi.mock("./api", () => ({ + apiCall: vi.fn(), +})); + +vi.mock("./config", () => ({ + readConfig: vi.fn(), + writeConfig: vi.fn(), +})); + +import { authenticate } from "./login"; +import { apiCall } from "./api"; +import { readConfig, writeConfig } from "./config"; + +const mockedApiCall = vi.mocked(apiCall); +const mockedReadConfig = vi.mocked(readConfig); +const mockedWriteConfig = vi.mocked(writeConfig); + +const makeProfile = (token?: ProfileConfig["token"]): ProfileConfig => ({ + credentials: { email: "test@example.com", password: "secret" }, + user: { uuid: "u1", name: "Test" }, + client: { uuid: "c1", name: "Client" }, + project: { uuid: "p1", name: "Project" }, + planningEvent: { uuid: "pe1", detail_uuid: "ped1", name: "PE" }, + workHours: { start: "09:00", end: "17:00" }, + timestamp: "2025-01-01T00:00:00", + token, +}); + +const validToken = { + access_token: "cached-token", + expires_at: DateTime.now().plus({ hours: 1 }).toISO()!, +}; + +const makeConfig = (profiles: Record): Config => ({ profiles }); + +const loginResponse = { + data: { + access_token: "new-token", + access_token_expires_at: DateTime.now().plus({ hours: 2 }).toSeconds(), + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("authenticate", () => { + it("returns cached token when not expired", async () => { + const profile = makeProfile(validToken); + mockedReadConfig.mockResolvedValue(makeConfig({ _default: profile })); + + const token = await authenticate(); + + expect(token).toBe("cached-token"); + expect(mockedApiCall).not.toHaveBeenCalled(); + }); + + it("re-authenticates when token is expired", async () => { + const profile = makeProfile({ + access_token: "cached-token", + expires_at: DateTime.now().minus({ hours: 1 }).toISO()!, + }); + mockedReadConfig.mockResolvedValue(makeConfig({ _default: profile })); + mockedApiCall.mockResolvedValue(loginResponse); + + const token = await authenticate(); + + expect(token).toBe("new-token"); + expect(mockedApiCall).toHaveBeenCalledWith("https://api2.sloneek.com/auth/login", { + method: "POST", + data: { email: "test@example.com", password: "secret" }, + }); + }); + + it("re-authenticates when no token exists", async () => { + const profile = makeProfile(); + mockedReadConfig.mockResolvedValue(makeConfig({ _default: profile })); + mockedApiCall.mockResolvedValue(loginResponse); + + const token = await authenticate(); + + expect(token).toBe("new-token"); + }); + + it("saves new token to config after login", async () => { + const profile = makeProfile(); + mockedReadConfig.mockResolvedValue(makeConfig({ _default: profile })); + mockedApiCall.mockResolvedValue(loginResponse); + + await authenticate(); + + expect(mockedWriteConfig).toHaveBeenCalledTimes(1); + const savedConfig = mockedWriteConfig.mock.calls[0][0] as Config; + expect(savedConfig.profiles._default.token?.access_token).toBe("new-token"); + expect(savedConfig.profiles._default.token?.expires_at).toBeDefined(); + }); + + it("uses named profile when provided", async () => { + const profile = makeProfile(validToken); + mockedReadConfig.mockResolvedValue(makeConfig({ work: profile })); + + const token = await authenticate("work"); + + expect(token).toBe("cached-token"); + }); + + it("falls back to _default when named profile does not exist", async () => { + const defaultProfile = makeProfile(validToken); + mockedReadConfig.mockResolvedValue(makeConfig({ _default: defaultProfile })); + + const token = await authenticate("nonexistent"); + + expect(token).toBe("cached-token"); + }); + + it("re-authenticates when token expires within 1 minute", async () => { + const profile = makeProfile({ + access_token: "cached-token", + expires_at: DateTime.now().plus({ seconds: 30 }).toISO()!, + }); + mockedReadConfig.mockResolvedValue(makeConfig({ _default: profile })); + mockedApiCall.mockResolvedValue(loginResponse); + + const token = await authenticate(); + + expect(token).toBe("new-token"); + }); +});