Skip to content

Commit

Permalink
test: api server integration tests (#106)
Browse files Browse the repository at this point in the history
* feat: outline integartion test

* test: add integration test to ci workflow

* fix: use docker compose action

* chore: add default config for runner

* fix: try mkdir before create file

* wip: try anki host name

* wip: ssh debug

* fix: use service container instead of docker compose

* refactor(ci): embed config file creation into test run step

* wip: test

* fix: try adding healthcheck option

* fix: install additional libatomic on amd64

* fix: keep adding missing dependencies

* chore: remove wip steps

* fix: integration test

* fix: linting errors
  • Loading branch information
Yukaii authored Feb 2, 2025
1 parent 005c1c8 commit f3eb031
Show file tree
Hide file tree
Showing 10 changed files with 186 additions and 13 deletions.
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,56 @@ jobs:
- name: Run tests
run: bun run test

integration:
runs-on: ubuntu-latest
services:
anki:
image: ghcr.io/yukaii/gakuon-headless-anki:latest
ports:
- 8765:8765
options: >-
--health-cmd "curl http://localhost:8765"
--health-interval 30s
--health-timeout 15s
--health-retries 3
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v1
with:
bun-version: latest

- name: Install dependencies
run: bun install

- name: Run integration tests
run: |
mkdir -p ~/.gakuon
cat <<'EOF' > ~/.gakuon/config.toml
# Gakuon Configuration File
# Generated on 2025-02-01T00:00:00.000Z
# Do not edit while Anki is running
[global]
ankiHost = "http://localhost:8765"
openaiApiKey = "${OPENAI_API_KEY}"
ttsVoice = "alloy"
[global.openai]
baseUrl = "https://api.openai.com/v1"
chatModel = "gpt-4o"
initModel = "gpt-4o"
ttsModel = "tts-1"
[global.cardOrder]
queueOrder = "learning_review_new"
reviewOrder = "due_date_random"
newCardOrder = "deck"
decks = []
EOF
bun run test:integration
build:
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 2 additions & 0 deletions deployment/Dockerfile.headless-anki
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
if [ "$TARGETARCH" = "arm64" ]; then \
apt-get install --no-install-recommends -y \
python3-pyqt6.qtquick python3-pyqt6.qtwebengine python3-pyqt6.qtmultimedia; \
else \
apt-get install --no-install-recommends -y libatomic1 libnss3 libxcomposite1 libxdamage1 libxtst6 libxfixes3 libxrandr2 libxrender1 libxcursor1 libxi6 libxss1 libxinerama1 libxkbfile1; \
fi && \
rm -rf /var/lib/apt/lists/*

Expand Down
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export default {
"^.+.tsx?$": ["ts-jest", {}],
},
testMatch: ["**/*.test.[jt]s?(x)"],
testPathIgnorePatterns: ["<rootDir>/tests/integration/"],
};
8 changes: 8 additions & 0 deletions jest.integration.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('ts-jest').JestConfigWithTsJest} **/
export default {
testEnvironment: "node",
transform: {
"^.+.tsx?$": ["ts-jest", {}],
},
testMatch: ["**/tests/integration/**/*.test.[jt]s?(x)"],
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"gakuon": "./dist/gakuon"
},
"scripts": {
"test:integration": "jest --config jest.integration.config.js",
"start": "bun run src/index.ts",
"build": "bun build src/index.ts --target=node --outfile dist/gakuon",
"prepublishOnly": "rm -rf dist && bun run build && bun run build:client",
Expand Down
1 change: 1 addition & 0 deletions src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,6 @@ export async function serve(options: ServeOptions = {}) {
// Start listening
server = app.listen(port, () => {
console.log(`Gakuon server running at http://localhost:${port}`);
console.log(`Using anki connect server at ${config.global.ankiHost}`)
});
}
8 changes: 4 additions & 4 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ export function loadConfig(customPath?: string): GakuonConfig {
`Invalid configuration from BASE64_GAKUON_CONFIG: ${error.message}. Issues: ${JSON.stringify(
error.issues,
null,
2
)}`
2,
)}`,
);
}
throw error;
Expand Down Expand Up @@ -185,8 +185,8 @@ export function loadConfig(customPath?: string): GakuonConfig {
`Invalid configuration from file ${configPath}: ${error.message}. Issues: ${JSON.stringify(
error.issues,
null,
2
)}`
2,
)}`,
);
}
throw error;
Expand Down
14 changes: 11 additions & 3 deletions src/services/anki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export class AnkiService {
],
});

if (!exists) {
throw new Error(`Card with ID: [${cardId}] doesn't exist in this deck`);
if (!exists.length || !exists[0]) {
return false;
}

return true;
Expand Down Expand Up @@ -366,6 +366,14 @@ export class AnkiService {
}

async sync(): Promise<void> {
await this.request("sync", {});
try {
await this.request("sync", {});
} catch (e: unknown) {
if ((e as { message?: string })?.message?.includes("auth not configured")) {
// Skip syncing when auth is not configured
return;
}
throw e;
}
}
}
17 changes: 11 additions & 6 deletions src/services/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,14 @@ export function createServer(deps: ServerDependencies) {
app.get(
"/api/decks",
asyncHandler(async (_req, res: Response) => {
const decks = await deps.ankiService.getDeckNames();
res.json({
decks: decks,
});
try {
const decks = await deps.ankiService.getDeckNames();
res.json({
decks: decks,
});
} catch (e: unknown) {
res.status(500).json({ error: (e as { message?: string })?.message || "Unknown error" });
}
}),
);

Expand All @@ -124,9 +128,10 @@ export function createServer(deps: ServerDependencies) {

const getCardDetails = async (req: Request, res: Response) => {
const cardId = Number.parseInt(req.params.id, 10);
const [card] = await deps.ankiService.getCardsInfo([cardId]);
const cards = await deps.ankiService.getCardsInfo([cardId]);
const card = (Array.isArray(cards) && cards.length > 0) ? cards[0] : null;

if (!card) {
if (!card || card.cardId == null) {
res.status(404).json({ error: "Card not found" });
return;
}
Expand Down
97 changes: 97 additions & 0 deletions tests/integration/api.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { spawn, type ChildProcess } from "node:child_process";
import supertest from "supertest";

let serverProcess: ChildProcess;
const API_URL = "http://localhost:4989";

// Helper function to wait for the server to start
async function waitForServer(url: string, timeout = 10000) {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
try {
const res = await supertest(url).get("/api/decks");
if (res.status < 500) return;
} catch (e) {
// server not ready yet
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error("Server did not start in time");
}

beforeAll(async () => {
// Spawn the API server process using the cli command
serverProcess = spawn("bun", ["run", "start", "serve", "-d"], {
env: { ...process.env, PORT: "4989" },
stdio: "inherit", // or "pipe" if you want to capture logs
});

// Optionally: Spawn headless anki or ensure it is already running

// Wait until the server is ready
await waitForServer(API_URL);
});

afterAll(() => {
if (serverProcess) {
serverProcess.kill();
}
// Optionally: Shut down the headless anki process/container
});

describe("Integration tests against running API", () => {
it("GET /api/decks returns decks", async () => {
const response = await supertest(API_URL).get("/api/decks");
expect(response.status).toBe(200);
expect(response.body).toHaveProperty("decks");
});

it("GET /api/decks/:name/cards returns cards array", async () => {
const response = await supertest(API_URL).get("/api/decks/NonExistentDeck/cards");
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
});

it("GET /api/cards/:id returns 404 for non-existent card", async () => {
const response = await supertest(API_URL).get("/api/cards/999999");
expect(response.status).toBe(404);
});

it("POST /api/cards/:id/answer rejects invalid ease value", async () => {
const response = await supertest(API_URL)
.post("/api/cards/9999/answer")
.send({ ease: 5 });
expect(response.status).toBe(400);
});

it("POST /api/cards/:id/answer returns 404 for non-existent card with valid ease", async () => {
const response = await supertest(API_URL)
.post("/api/cards/9999/answer")
.send({ ease: 3 });
expect(response.status).toBe(404);
});

it("POST /api/cards/:id/regenerate returns 404 for non-existent card", async () => {
const response = await supertest(API_URL)
.post("/api/cards/9999/regenerate");
expect(response.status).toBe(404);
});

it("GET /api/audio/:filename returns 404 for missing audio file", async () => {
const response = await supertest(API_URL).get("/api/audio/missing.mp3");
expect(response.status).toBe(404);
});

it("GET /api/decks/:name/config returns 404 for non-existent deck config", async () => {
const response = await supertest(API_URL).get("/api/decks/NonExistentDeck/config");
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: "Deck configuration not found" });
});

it("POST /api/sync returns success", async () => {
const response = await supertest(API_URL)
.post("/api/sync");
expect(response.status).toBe(200);
expect(response.body).toEqual({ success: true });
});
});

0 comments on commit f3eb031

Please sign in to comment.