diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..9db6f16 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,31 @@ +--- + +kind: pipeline +name: server_build_and_test + +steps: + - name: build_server + image: node + commands: + - npm ci -w @calchat/shared + - npm ci -w @calchat/server + - npm run build -w @calchat/server + + - name: jest_server + image: node + commands: + - npm run test -w @calchat/server + +--- + +kind: pipeline +name: check_for_formatting + +steps: + - name: format_check + image: node + commands: + - npm ci + - npm run check_format + +--- diff --git a/CLAUDE.md b/CLAUDE.md index a3a2a87..21b2fc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ This is a fullstack TypeScript monorepo with npm workspaces. ```bash npm install # Install all dependencies for all workspaces npm run format # Format all TypeScript files with Prettier +npm run check_format # Check formatting without modifying files (used in CI) ``` ### Client (apps/client) - Expo React Native app @@ -36,6 +37,7 @@ npm run build -w @calchat/shared # Compile shared types to dist/ npm run dev -w @calchat/server # Build shared + start dev server with hot reload (tsx watch) npm run build -w @calchat/server # Build shared + compile TypeScript npm run start -w @calchat/server # Run compiled server (port 3000) +npm run test -w @calchat/server # Run Jest unit tests ``` ## Technology Stack @@ -58,7 +60,9 @@ npm run start -w @calchat/server # Run compiled server (port 3000) | | react-native-logs | Client-side logging | | | tsdav | CalDAV client library | | | ical.js | iCalendar parsing/generation | +| Testing | Jest / ts-jest | Server unit tests | | Deployment | Docker | Server containerization (multi-stage build) | +| | Drone CI | CI/CD pipelines (build, test, format check) | | Planned | iCalendar | Event export/import | ## Architecture @@ -694,6 +698,21 @@ This uses the `preview` profile from `eas.json` which builds an APK with: - Package name: `com.gilmour109.calchat` - EAS Project ID: `b722dde6-7d89-48ff-9095-e007e7c7da87` +## CI/CD (Drone) + +The project uses Drone CI (`.drone.yml`) with two pipelines: + +1. **`server_build_and_test`**: Builds the server (`npm ci` + `npm run build`) and runs Jest tests (`npm run test`) +2. **`check_for_formatting`**: Checks Prettier formatting across all workspaces (`npm run check_format`) + +## Testing + +Server uses Jest with ts-jest for unit testing. Config in `apps/server/jest.config.js` ignores `/node_modules/` and `/dist/`. + +**Existing tests:** +- `src/utils/password.test.ts` - Tests for bcrypt hash() and compare() +- `src/utils/recurrenceExpander.test.ts` - Tests for expandRecurringEvents() (non-recurring, weekly/daily/UNTIL recurrence, EXDATE filtering, RRULE: prefix stripping, invalid RRULE fallback, multi-day events, sorting) + ## Documentation Detailed architecture diagrams are in `docs/`: diff --git a/apps/client/src/app/(tabs)/calendar.tsx b/apps/client/src/app/(tabs)/calendar.tsx index c72ff14..c14b316 100644 --- a/apps/client/src/app/(tabs)/calendar.tsx +++ b/apps/client/src/app/(tabs)/calendar.tsx @@ -623,7 +623,7 @@ const CalendarToolbar = ({ loadEvents }: CalendarToolbarProps) => { elevation: 4, }; - const className = "flex flex-row items-center gap-2 px-3 py-1 rounded-lg" + const className = "flex flex-row items-center gap-2 px-3 py-1 rounded-lg"; return ( - {title}: + + {title}: + { ); const saveConfig = async () => { - showFeedback(setSaveFeedback, saveTimer, "Speichere Konfiguration...", false, true); + showFeedback( + setSaveFeedback, + saveTimer, + "Speichere Konfiguration...", + false, + true, + ); try { const saved = await CaldavConfigService.saveConfig( serverUrl, @@ -119,9 +127,19 @@ const CaldavSettings = () => { password, ); setConfig(saved); - showFeedback(setSaveFeedback, saveTimer, "Konfiguration wurde gespeichert", false); + showFeedback( + setSaveFeedback, + saveTimer, + "Konfiguration wurde gespeichert", + false, + ); } catch { - showFeedback(setSaveFeedback, saveTimer, "Fehler beim Speichern der Konfiguration", true); + showFeedback( + setSaveFeedback, + saveTimer, + "Fehler beim Speichern der Konfiguration", + true, + ); } }; @@ -129,9 +147,19 @@ const CaldavSettings = () => { showFeedback(setSyncFeedback, syncTimer, "Synchronisiere...", false, true); try { await CaldavConfigService.sync(); - showFeedback(setSyncFeedback, syncTimer, "Synchronisierung erfolgreich", false); + showFeedback( + setSyncFeedback, + syncTimer, + "Synchronisierung erfolgreich", + false, + ); } catch { - showFeedback(setSyncFeedback, syncTimer, "Fehler beim Synchronisieren", true); + showFeedback( + setSyncFeedback, + syncTimer, + "Fehler beim Synchronisieren", + true, + ); } }; diff --git a/apps/server/jest.config.js b/apps/server/jest.config.js index a93551f..28a7fa3 100644 --- a/apps/server/jest.config.js +++ b/apps/server/jest.config.js @@ -1,4 +1,5 @@ module.exports = { preset: "ts-jest", testEnvironment: "node", + testPathIgnorePatterns: ["/node_modules/", "/dist/"], }; diff --git a/apps/server/src/controllers/AuthController.ts b/apps/server/src/controllers/AuthController.ts index e1e9136..0e02879 100644 --- a/apps/server/src/controllers/AuthController.ts +++ b/apps/server/src/controllers/AuthController.ts @@ -21,5 +21,4 @@ export class AuthController { res.status(400).json({ error: (error as Error).message }); } } - } diff --git a/apps/server/src/controllers/CaldavController.ts b/apps/server/src/controllers/CaldavController.ts index a389208..0f821e7 100644 --- a/apps/server/src/controllers/CaldavController.ts +++ b/apps/server/src/controllers/CaldavController.ts @@ -15,7 +15,10 @@ export class CaldavController { const response = await this.caldavService.saveConfig(config); res.json(response); } catch (error) { - log.error({ err: error, userId: req.user?.userId }, "Error saving config"); + log.error( + { err: error, userId: req.user?.userId }, + "Error saving config", + ); res.status(500).json({ error: "Failed to save config" }); } } @@ -30,7 +33,10 @@ export class CaldavController { // Don't expose the password to the client res.json(config); } catch (error) { - log.error({ err: error, userId: req.user?.userId }, "Error loading config"); + log.error( + { err: error, userId: req.user?.userId }, + "Error loading config", + ); res.status(500).json({ error: "Failed to load config" }); } } @@ -40,7 +46,10 @@ export class CaldavController { await this.caldavService.deleteConfig(req.user!.userId); res.status(204).send(); } catch (error) { - log.error({ err: error, userId: req.user?.userId }, "Error deleting config"); + log.error( + { err: error, userId: req.user?.userId }, + "Error deleting config", + ); res.status(500).json({ error: "Failed to delete config" }); } } @@ -50,7 +59,10 @@ export class CaldavController { const events = await this.caldavService.pullEvents(req.user!.userId); res.json(events); } catch (error) { - log.error({ err: error, userId: req.user?.userId }, "Error pulling events"); + log.error( + { err: error, userId: req.user?.userId }, + "Error pulling events", + ); res.status(500).json({ error: "Failed to pull events" }); } } @@ -60,7 +72,10 @@ export class CaldavController { await this.caldavService.pushAll(req.user!.userId); res.status(204).send(); } catch (error) { - log.error({ err: error, userId: req.user?.userId }, "Error pushing events"); + log.error( + { err: error, userId: req.user?.userId }, + "Error pushing events", + ); res.status(500).json({ error: "Failed to push events" }); } } @@ -78,7 +93,10 @@ export class CaldavController { await this.caldavService.pushEvent(req.user!.userId, event); res.status(204).send(); } catch (error) { - log.error({ err: error, userId: req.user?.userId }, "Error pushing event"); + log.error( + { err: error, userId: req.user?.userId }, + "Error pushing event", + ); res.status(500).json({ error: "Failed to push event" }); } } diff --git a/apps/server/src/controllers/EventController.ts b/apps/server/src/controllers/EventController.ts index 0aa5478..5b3db0d 100644 --- a/apps/server/src/controllers/EventController.ts +++ b/apps/server/src/controllers/EventController.ts @@ -40,7 +40,10 @@ export class EventController { await this.pushToCaldav(userId, event); res.status(201).json(event); } catch (error) { - log.error({ err: error, userId: req.user?.userId }, "Error creating event"); + log.error( + { err: error, userId: req.user?.userId }, + "Error creating event", + ); res.status(500).json({ error: "Failed to create event" }); } } @@ -67,7 +70,10 @@ export class EventController { const events = await this.eventService.getAll(req.user!.userId); res.json(events); } catch (error) { - log.error({ err: error, userId: req.user?.userId }, "Error getting events"); + log.error( + { err: error, userId: req.user?.userId }, + "Error getting events", + ); res.status(500).json({ error: "Failed to get events" }); } } diff --git a/apps/server/src/services/AuthService.ts b/apps/server/src/services/AuthService.ts index 0ef3c6d..cef2f6b 100644 --- a/apps/server/src/services/AuthService.ts +++ b/apps/server/src/services/AuthService.ts @@ -44,5 +44,4 @@ export class AuthService { return { user, accessToken: "" }; } - } diff --git a/apps/server/src/services/CaldavService.test.ts b/apps/server/src/services/CaldavService.test.ts deleted file mode 100644 index 870b3e9..0000000 --- a/apps/server/src/services/CaldavService.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -// import { createLogger } from "../logging"; -// import { CaldavService } from "./CaldavService"; -// -// const logger = createLogger("CaldavService-Test"); -// -// const cdService = new CaldavService(); -// -// test("print events", async () => { -// const client = await cdService.login(); -// await cdService.pullEvents(client); -// }); diff --git a/apps/server/src/utils/password.test.ts b/apps/server/src/utils/password.test.ts new file mode 100644 index 0000000..fb8630f --- /dev/null +++ b/apps/server/src/utils/password.test.ts @@ -0,0 +1,37 @@ +import { hash, compare } from "./password"; + +describe("password", () => { + describe("hash()", () => { + it("returns a valid bcrypt hash", async () => { + const result = await hash("testpassword"); + expect(result).toMatch(/^\$2b\$/); + }); + + it("produces different hashes for the same password (salt)", async () => { + const hash1 = await hash("samepassword"); + const hash2 = await hash("samepassword"); + expect(hash1).not.toBe(hash2); + }); + }); + + describe("compare()", () => { + it("returns true for the correct password", async () => { + const hashed = await hash("correct"); + const result = await compare("correct", hashed); + expect(result).toBe(true); + }); + + it("returns false for a wrong password", async () => { + const hashed = await hash("correct"); + const result = await compare("wrong", hashed); + expect(result).toBe(false); + }); + + it("handles special characters and unicode", async () => { + const password = "p@$$w0rd!#%& äöü 🔑"; + const hashed = await hash(password); + expect(await compare(password, hashed)).toBe(true); + expect(await compare("other", hashed)).toBe(false); + }); + }); +}); diff --git a/apps/server/src/utils/recurrenceExpander.test.ts b/apps/server/src/utils/recurrenceExpander.test.ts new file mode 100644 index 0000000..95a39fe --- /dev/null +++ b/apps/server/src/utils/recurrenceExpander.test.ts @@ -0,0 +1,210 @@ +import { CalendarEvent } from "@calchat/shared"; +import { expandRecurringEvents } from "./recurrenceExpander"; + +// Helper: create a CalendarEvent with sensible defaults +function makeEvent( + overrides: Partial & { startTime: Date; endTime: Date }, +): CalendarEvent { + return { + id: "evt-1", + userId: "user-1", + title: "Test Event", + ...overrides, + }; +} + +// Helper: create a date from "YYYY-MM-DD HH:mm" (local time) +function d(dateStr: string): Date { + const [datePart, timePart] = dateStr.split(" "); + const [y, m, day] = datePart.split("-").map(Number); + if (timePart) { + const [h, min] = timePart.split(":").map(Number); + return new Date(y, m - 1, day, h, min); + } + return new Date(y, m - 1, day); +} + +describe("expandRecurringEvents", () => { + // Range: 2025-06-01 to 2025-06-30 + const rangeStart = d("2025-06-01 00:00"); + const rangeEnd = d("2025-06-30 23:59"); + + describe("non-recurring events", () => { + it("returns event within the range", () => { + const event = makeEvent({ + startTime: d("2025-06-10 09:00"), + endTime: d("2025-06-10 10:00"), + }); + + const result = expandRecurringEvents([event], rangeStart, rangeEnd); + + expect(result).toHaveLength(1); + expect(result[0].occurrenceStart).toEqual(d("2025-06-10 09:00")); + expect(result[0].occurrenceEnd).toEqual(d("2025-06-10 10:00")); + }); + + it("excludes event outside the range", () => { + const event = makeEvent({ + startTime: d("2025-07-05 09:00"), + endTime: d("2025-07-05 10:00"), + }); + + const result = expandRecurringEvents([event], rangeStart, rangeEnd); + + expect(result).toHaveLength(0); + }); + + it("includes event that starts before range and ends within", () => { + const event = makeEvent({ + startTime: d("2025-05-31 22:00"), + endTime: d("2025-06-01 02:00"), + }); + + const result = expandRecurringEvents([event], rangeStart, rangeEnd); + + expect(result).toHaveLength(1); + }); + + it("includes event that spans the entire range", () => { + const event = makeEvent({ + startTime: d("2025-05-01 00:00"), + endTime: d("2025-07-31 23:59"), + }); + + const result = expandRecurringEvents([event], rangeStart, rangeEnd); + + expect(result).toHaveLength(1); + }); + + it("returns empty array for empty input", () => { + const result = expandRecurringEvents([], rangeStart, rangeEnd); + + expect(result).toEqual([]); + }); + }); + + describe("recurring events", () => { + it("expands weekly event to all occurrences in range", () => { + // Weekly on Mondays, starting 2025-06-02 (a Monday) + const event = makeEvent({ + startTime: d("2025-06-02 10:00"), + endTime: d("2025-06-02 11:00"), + recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", + }); + + const result = expandRecurringEvents([event], rangeStart, rangeEnd); + + // Mondays in June 2025: 2, 9, 16, 23, 30 + expect(result).toHaveLength(5); + expect(result[0].occurrenceStart).toEqual(d("2025-06-02 10:00")); + expect(result[1].occurrenceStart).toEqual(d("2025-06-09 10:00")); + expect(result[2].occurrenceStart).toEqual(d("2025-06-16 10:00")); + expect(result[3].occurrenceStart).toEqual(d("2025-06-23 10:00")); + expect(result[4].occurrenceStart).toEqual(d("2025-06-30 10:00")); + }); + + it("daily event with UNTIL stops at the right date", () => { + const event = makeEvent({ + startTime: d("2025-06-01 08:00"), + endTime: d("2025-06-01 09:00"), + recurrenceRule: "FREQ=DAILY;UNTIL=20250605T235959", + }); + + const result = expandRecurringEvents([event], rangeStart, rangeEnd); + + // June 1, 2, 3, 4, 5 + expect(result).toHaveLength(5); + expect(result[4].occurrenceStart).toEqual(d("2025-06-05 08:00")); + }); + + it("skips occurrences on exception dates (EXDATE)", () => { + const event = makeEvent({ + startTime: d("2025-06-02 10:00"), + endTime: d("2025-06-02 11:00"), + recurrenceRule: "FREQ=WEEKLY;BYDAY=MO", + exceptionDates: ["2025-06-09", "2025-06-23"], + }); + + const result = expandRecurringEvents([event], rangeStart, rangeEnd); + + // 5 Mondays minus 2 exceptions = 3 + expect(result).toHaveLength(3); + const dates = result.map((r) => r.occurrenceStart.getDate()); + expect(dates).toEqual([2, 16, 30]); + }); + + it("handles RRULE: prefix (strips it)", () => { + const event = makeEvent({ + startTime: d("2025-06-01 08:00"), + endTime: d("2025-06-01 09:00"), + recurrenceRule: "RRULE:FREQ=DAILY;COUNT=3", + }); + + const result = expandRecurringEvents([event], rangeStart, rangeEnd); + + expect(result).toHaveLength(3); + }); + + it("falls back to single occurrence on invalid RRULE", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + + const event = makeEvent({ + startTime: d("2025-06-10 09:00"), + endTime: d("2025-06-10 10:00"), + recurrenceRule: "COMPLETELY_INVALID_RULE", + }); + + const result = expandRecurringEvents([event], rangeStart, rangeEnd); + + expect(result).toHaveLength(1); + expect(result[0].occurrenceStart).toEqual(d("2025-06-10 09:00")); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe("multi-day events", () => { + it("finds event starting before range that ends within range", () => { + // 3-day recurring event starting May 15, weekly + const event = makeEvent({ + startTime: d("2025-05-15 08:00"), + endTime: d("2025-05-18 08:00"), + recurrenceRule: "FREQ=WEEKLY", + }); + + // The occurrence starting May 29 ends June 1 → overlaps with range + const result = expandRecurringEvents([event], rangeStart, rangeEnd); + + const starts = result.map((r) => r.occurrenceStart.getDate()); + // May 29 (ends June 1), June 5, 12, 19, 26 + expect(starts).toContain(29); // May 29 + }); + }); + + describe("sorting", () => { + it("returns events sorted by occurrenceStart", () => { + const laterEvent = makeEvent({ + id: "evt-later", + startTime: d("2025-06-20 14:00"), + endTime: d("2025-06-20 15:00"), + }); + const earlierEvent = makeEvent({ + id: "evt-earlier", + startTime: d("2025-06-05 09:00"), + endTime: d("2025-06-05 10:00"), + }); + + // Pass in reverse order + const result = expandRecurringEvents( + [laterEvent, earlierEvent], + rangeStart, + rangeEnd, + ); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe("evt-earlier"); + expect(result[1].id).toBe("evt-later"); + }); + }); +}); diff --git a/package.json b/package.json index 30516f3..eff97c5 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "packages/*" ], "scripts": { - "format": "prettier --write \"apps/*/src/**/*.{ts,tsx}\" \"packages/*/src/**/*.ts\"" + "format": "prettier --write \"apps/*/src/**/*.{ts,tsx}\" \"packages/*/src/**/*.ts\"", + "check_format": "prettier --check \"apps/*/src/**/*.{ts,tsx}\" \"packages/*/src/**/*.ts\"" }, "devDependencies": { "eslint": "^9.25.0",