Format the shared package and drop an unused import to satisfy the lint gate

The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass.
This commit is contained in:
Maurice
2026-05-31 20:22:54 +02:00
parent 64bd0de7e8
commit fb36ae5678
50 changed files with 1131 additions and 378 deletions
+44 -10
View File
@@ -1,33 +1,67 @@
import {
adminUserCreateRequestSchema,
adminPermissionsRequestSchema,
adminInviteCreateRequestSchema,
adminFeatureToggleRequestSchema,
} from './admin.schema';
import { describe, it, expect } from 'vitest';
import { adminUserCreateRequestSchema, adminPermissionsRequestSchema, adminInviteCreateRequestSchema, adminFeatureToggleRequestSchema } from './admin.schema';
describe('adminUserCreateRequestSchema', () => {
it('requires an email; role limited to user/admin', () => {
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', password: 'p', role: 'admin' }).success).toBe(true);
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(true);
expect(adminUserCreateRequestSchema.safeParse({ password: 'p' }).success).toBe(false);
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' }).success).toBe(false);
expect(
adminUserCreateRequestSchema.safeParse({
email: 'a@b.c',
password: 'p',
role: 'admin',
}).success,
).toBe(true);
expect(
adminUserCreateRequestSchema.safeParse({ email: 'a@b.c' }).success,
).toBe(true);
expect(
adminUserCreateRequestSchema.safeParse({ password: 'p' }).success,
).toBe(false);
expect(
adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' })
.success,
).toBe(false);
});
});
describe('adminPermissionsRequestSchema', () => {
it('requires a permissions record', () => {
expect(adminPermissionsRequestSchema.safeParse({ permissions: { trip_edit: { user: true } } }).success).toBe(true);
expect(
adminPermissionsRequestSchema.safeParse({
permissions: { trip_edit: { user: true } },
}).success,
).toBe(true);
expect(adminPermissionsRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('adminInviteCreateRequestSchema', () => {
it('accepts optional uses/expiry/role', () => {
expect(adminInviteCreateRequestSchema.safeParse({ max_uses: 5, expires_in_days: 7 }).success).toBe(true);
expect(
adminInviteCreateRequestSchema.safeParse({
max_uses: 5,
expires_in_days: 7,
}).success,
).toBe(true);
expect(adminInviteCreateRequestSchema.safeParse({}).success).toBe(true);
expect(adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success).toBe(false);
expect(
adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success,
).toBe(false);
});
});
describe('adminFeatureToggleRequestSchema', () => {
it('requires a boolean enabled', () => {
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success).toBe(true);
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
expect(
adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success,
).toBe(true);
expect(
adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success,
).toBe(false);
});
});
+12 -4
View File
@@ -14,21 +14,29 @@ export const adminUserCreateRequestSchema = z.object({
username: z.string().optional(),
role: z.enum(['user', 'admin']).optional(),
});
export type AdminUserCreateRequest = z.infer<typeof adminUserCreateRequestSchema>;
export type AdminUserCreateRequest = z.infer<
typeof adminUserCreateRequestSchema
>;
export const adminPermissionsRequestSchema = z.object({
permissions: z.record(z.string(), z.unknown()),
});
export type AdminPermissionsRequest = z.infer<typeof adminPermissionsRequestSchema>;
export type AdminPermissionsRequest = z.infer<
typeof adminPermissionsRequestSchema
>;
export const adminInviteCreateRequestSchema = z.object({
max_uses: z.number().optional(),
expires_in_days: z.number().optional(),
role: z.enum(['user', 'admin']).optional(),
});
export type AdminInviteCreateRequest = z.infer<typeof adminInviteCreateRequestSchema>;
export type AdminInviteCreateRequest = z.infer<
typeof adminInviteCreateRequestSchema
>;
export const adminFeatureToggleRequestSchema = z.object({
enabled: z.boolean(),
});
export type AdminFeatureToggleRequest = z.infer<typeof adminFeatureToggleRequestSchema>;
export type AdminFeatureToggleRequest = z.infer<
typeof adminFeatureToggleRequestSchema
>;
+22 -7
View File
@@ -1,20 +1,35 @@
import { describe, it, expect } from 'vitest';
import { airportSchema, airportSearchQuerySchema } from './airport.schema';
import { describe, it, expect } from 'vitest';
describe('airportSchema', () => {
it('accepts a full airport record', () => {
const parsed = airportSchema.parse({
iata: 'BER', icao: 'EDDB', name: 'Berlin Brandenburg', city: 'Berlin',
country: 'DE', lat: 52.36, lng: 13.5, tz: 'Europe/Berlin',
iata: 'BER',
icao: 'EDDB',
name: 'Berlin Brandenburg',
city: 'Berlin',
country: 'DE',
lat: 52.36,
lng: 13.5,
tz: 'Europe/Berlin',
});
expect(parsed.iata).toBe('BER');
});
it('allows a null icao (smaller fields can be missing one)', () => {
expect(airportSchema.safeParse({
iata: 'XXX', icao: null, name: 'Test', city: 'Test', country: 'DE',
lat: 0, lng: 0, tz: 'UTC',
}).success).toBe(true);
expect(
airportSchema.safeParse({
iata: 'XXX',
icao: null,
name: 'Test',
city: 'Test',
country: 'DE',
lat: 0,
lng: 0,
tz: 'UTC',
}).success,
).toBe(true);
});
});
@@ -1,29 +1,45 @@
import { describe, it, expect } from 'vitest';
import {
assignmentCreateRequestSchema,
assignmentMoveRequestSchema,
assignmentParticipantsRequestSchema,
} from './assignment.schema';
import { describe, it, expect } from 'vitest';
describe('assignmentCreateRequestSchema', () => {
it('requires a place_id; notes optional/nullable', () => {
expect(assignmentCreateRequestSchema.safeParse({ place_id: 2 }).success).toBe(true);
expect(assignmentCreateRequestSchema.safeParse({ place_id: '2', notes: null }).success).toBe(true);
expect(
assignmentCreateRequestSchema.safeParse({ place_id: 2 }).success,
).toBe(true);
expect(
assignmentCreateRequestSchema.safeParse({ place_id: '2', notes: null })
.success,
).toBe(true);
expect(assignmentCreateRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('assignmentMoveRequestSchema', () => {
it('requires new_day_id; order_index optional', () => {
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4 }).success).toBe(true);
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: 0 }).success).toBe(true);
expect(
assignmentMoveRequestSchema.safeParse({ new_day_id: 4 }).success,
).toBe(true);
expect(
assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: 0 })
.success,
).toBe(true);
expect(assignmentMoveRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('assignmentParticipantsRequestSchema', () => {
it('requires a numeric user_ids array', () => {
expect(assignmentParticipantsRequestSchema.safeParse({ user_ids: [1, 2] }).success).toBe(true);
expect(assignmentParticipantsRequestSchema.safeParse({ user_ids: 'no' }).success).toBe(false);
expect(
assignmentParticipantsRequestSchema.safeParse({ user_ids: [1, 2] })
.success,
).toBe(true);
expect(
assignmentParticipantsRequestSchema.safeParse({ user_ids: 'no' }).success,
).toBe(false);
});
});
+11 -4
View File
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { assignmentPlaceSchema } from '../place/place.schema';
import { z } from 'zod';
/**
* Assignment API contract — single source of truth for the place↔day itinerary
* endpoints under /api/trips/:tripId/days/:dayId/assignments and
@@ -48,12 +49,16 @@ export const assignmentCreateRequestSchema = z.object({
place_id: z.union([z.number(), z.string()]),
notes: z.string().nullable().optional(),
});
export type AssignmentCreateRequest = z.infer<typeof assignmentCreateRequestSchema>;
export type AssignmentCreateRequest = z.infer<
typeof assignmentCreateRequestSchema
>;
export const assignmentReorderRequestSchema = z.object({
orderedIds: z.array(z.number()),
});
export type AssignmentReorderRequest = z.infer<typeof assignmentReorderRequestSchema>;
export type AssignmentReorderRequest = z.infer<
typeof assignmentReorderRequestSchema
>;
export const assignmentMoveRequestSchema = z.object({
new_day_id: z.union([z.number(), z.string()]),
@@ -70,4 +75,6 @@ export type AssignmentTimeRequest = z.infer<typeof assignmentTimeRequestSchema>;
export const assignmentParticipantsRequestSchema = z.object({
user_ids: z.array(z.number()),
});
export type AssignmentParticipantsRequest = z.infer<typeof assignmentParticipantsRequestSchema>;
export type AssignmentParticipantsRequest = z.infer<
typeof assignmentParticipantsRequestSchema
>;
+33 -8
View File
@@ -1,29 +1,54 @@
import { describe, it, expect } from 'vitest';
import {
markRegionRequestSchema,
createBucketItemRequestSchema,
regionGeoSchema,
} from './atlas.schema';
import { describe, it, expect } from 'vitest';
describe('markRegionRequestSchema', () => {
it('requires both name and country_code', () => {
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria', country_code: 'DE' }).success).toBe(true);
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria' }).success).toBe(false);
expect(
markRegionRequestSchema.safeParse({ name: 'Bavaria', country_code: 'DE' })
.success,
).toBe(true);
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria' }).success).toBe(
false,
);
});
});
describe('createBucketItemRequestSchema', () => {
it('requires a name; coordinates and metadata optional/nullable', () => {
expect(createBucketItemRequestSchema.safeParse({ name: 'Tokyo' }).success).toBe(true);
expect(createBucketItemRequestSchema.safeParse({ name: 'Tokyo', lat: 35, lng: 139, country_code: null }).success).toBe(true);
expect(
createBucketItemRequestSchema.safeParse({ name: 'Tokyo' }).success,
).toBe(true);
expect(
createBucketItemRequestSchema.safeParse({
name: 'Tokyo',
lat: 35,
lng: 139,
country_code: null,
}).success,
).toBe(true);
expect(createBucketItemRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('regionGeoSchema', () => {
it('accepts a FeatureCollection with opaque features', () => {
expect(regionGeoSchema.safeParse({ type: 'FeatureCollection', features: [] }).success).toBe(true);
expect(regionGeoSchema.safeParse({ type: 'FeatureCollection', features: [{ anything: true }] }).success).toBe(true);
expect(regionGeoSchema.safeParse({ type: 'Other', features: [] }).success).toBe(false);
expect(
regionGeoSchema.safeParse({ type: 'FeatureCollection', features: [] })
.success,
).toBe(true);
expect(
regionGeoSchema.safeParse({
type: 'FeatureCollection',
features: [{ anything: true }],
}).success,
).toBe(true);
expect(
regionGeoSchema.safeParse({ type: 'Other', features: [] }).success,
).toBe(false);
});
});
+6 -2
View File
@@ -29,7 +29,9 @@ export const createBucketItemRequestSchema = z.object({
notes: z.string().nullable().optional(),
target_date: z.string().nullable().optional(),
});
export type CreateBucketItemRequest = z.infer<typeof createBucketItemRequestSchema>;
export type CreateBucketItemRequest = z.infer<
typeof createBucketItemRequestSchema
>;
export const updateBucketItemRequestSchema = z.object({
name: z.string().optional(),
@@ -39,7 +41,9 @@ export const updateBucketItemRequestSchema = z.object({
country_code: z.string().nullable().optional(),
target_date: z.string().nullable().optional(),
});
export type UpdateBucketItemRequest = z.infer<typeof updateBucketItemRequestSchema>;
export type UpdateBucketItemRequest = z.infer<
typeof updateBucketItemRequestSchema
>;
/** A bucket-list item row (DB-shaped; kept open). */
export const bucketItemSchema = open;
+61 -16
View File
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import {
registerRequestSchema,
loginRequestSchema,
@@ -10,38 +9,84 @@ import {
mcpTokenCreateRequestSchema,
} from './auth.schema';
import { describe, it, expect } from 'vitest';
describe('registerRequestSchema', () => {
it('requires email + password; username/invite optional', () => {
expect(registerRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success).toBe(true);
expect(registerRequestSchema.safeParse({ email: 'a@b.c', password: 'pw', invite_token: 't' }).success).toBe(true);
expect(registerRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(false);
expect(
registerRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' })
.success,
).toBe(true);
expect(
registerRequestSchema.safeParse({
email: 'a@b.c',
password: 'pw',
invite_token: 't',
}).success,
).toBe(true);
expect(registerRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(
false,
);
});
});
describe('loginRequestSchema', () => {
it('requires email + password', () => {
expect(loginRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success).toBe(true);
expect(loginRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(false);
expect(
loginRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success,
).toBe(true);
expect(loginRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(
false,
);
});
});
describe('forgot/reset/change password schemas', () => {
it('validate their required fields', () => {
expect(forgotPasswordRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(true);
expect(resetPasswordRequestSchema.safeParse({ token: 't', new_password: 'pw' }).success).toBe(true);
expect(resetPasswordRequestSchema.safeParse({ token: 't', new_password: 'pw', mfa_code: '123456' }).success).toBe(true);
expect(resetPasswordRequestSchema.safeParse({ new_password: 'pw' }).success).toBe(false);
expect(changePasswordRequestSchema.safeParse({ current_password: 'a', new_password: 'b' }).success).toBe(true);
expect(changePasswordRequestSchema.safeParse({ new_password: 'b' }).success).toBe(false);
expect(
forgotPasswordRequestSchema.safeParse({ email: 'a@b.c' }).success,
).toBe(true);
expect(
resetPasswordRequestSchema.safeParse({ token: 't', new_password: 'pw' })
.success,
).toBe(true);
expect(
resetPasswordRequestSchema.safeParse({
token: 't',
new_password: 'pw',
mfa_code: '123456',
}).success,
).toBe(true);
expect(
resetPasswordRequestSchema.safeParse({ new_password: 'pw' }).success,
).toBe(false);
expect(
changePasswordRequestSchema.safeParse({
current_password: 'a',
new_password: 'b',
}).success,
).toBe(true);
expect(
changePasswordRequestSchema.safeParse({ new_password: 'b' }).success,
).toBe(false);
});
});
describe('mfa + mcp-token schemas', () => {
it('validate their fields', () => {
expect(mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't', code: '123456' }).success).toBe(true);
expect(mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't' }).success).toBe(false);
expect(mfaEnableRequestSchema.safeParse({ code: '123456' }).success).toBe(true);
expect(mcpTokenCreateRequestSchema.safeParse({ name: 'CLI' }).success).toBe(true);
expect(
mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't', code: '123456' })
.success,
).toBe(true);
expect(
mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't' }).success,
).toBe(false);
expect(mfaEnableRequestSchema.safeParse({ code: '123456' }).success).toBe(
true,
);
expect(mcpTokenCreateRequestSchema.safeParse({ name: 'CLI' }).success).toBe(
true,
);
expect(mcpTokenCreateRequestSchema.safeParse({}).success).toBe(true);
});
});
+16 -4
View File
@@ -1,14 +1,26 @@
import { describe, it, expect } from 'vitest';
import { autoBackupSettingsRequestSchema } from './backup.schema';
import { describe, it, expect } from 'vitest';
describe('autoBackupSettingsRequestSchema', () => {
it('accepts the known toggles and stays permissive for extras', () => {
expect(autoBackupSettingsRequestSchema.safeParse({ enabled: true, interval: 'daily', keep_days: 7 }).success).toBe(true);
expect(autoBackupSettingsRequestSchema.safeParse({ enabled: false, foo: 'bar' }).success).toBe(true);
expect(
autoBackupSettingsRequestSchema.safeParse({
enabled: true,
interval: 'daily',
keep_days: 7,
}).success,
).toBe(true);
expect(
autoBackupSettingsRequestSchema.safeParse({ enabled: false, foo: 'bar' })
.success,
).toBe(true);
expect(autoBackupSettingsRequestSchema.safeParse({}).success).toBe(true);
});
it('rejects a non-boolean enabled', () => {
expect(autoBackupSettingsRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
expect(
autoBackupSettingsRequestSchema.safeParse({ enabled: 'yes' }).success,
).toBe(false);
});
});
+3 -1
View File
@@ -16,4 +16,6 @@ export const autoBackupSettingsRequestSchema = z
time: z.string().optional(),
})
.passthrough();
export type AutoBackupSettingsRequest = z.infer<typeof autoBackupSettingsRequestSchema>;
export type AutoBackupSettingsRequest = z.infer<
typeof autoBackupSettingsRequestSchema
>;
+31 -9
View File
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import {
budgetCreateItemRequestSchema,
budgetUpdateMembersRequestSchema,
@@ -6,31 +5,54 @@ import {
budgetReorderItemsRequestSchema,
} from './budget.schema';
import { describe, it, expect } from 'vitest';
describe('budgetCreateItemRequestSchema', () => {
it('requires a name; money/meta fields optional + nullable', () => {
expect(budgetCreateItemRequestSchema.safeParse({ name: 'Hotel' }).success).toBe(true);
expect(budgetCreateItemRequestSchema.safeParse({ name: 'Hotel', total_price: 200, persons: null }).success).toBe(true);
expect(
budgetCreateItemRequestSchema.safeParse({ name: 'Hotel' }).success,
).toBe(true);
expect(
budgetCreateItemRequestSchema.safeParse({
name: 'Hotel',
total_price: 200,
persons: null,
}).success,
).toBe(true);
expect(budgetCreateItemRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('budgetUpdateMembersRequestSchema', () => {
it('requires a numeric user_ids array', () => {
expect(budgetUpdateMembersRequestSchema.safeParse({ user_ids: [1, 2] }).success).toBe(true);
expect(budgetUpdateMembersRequestSchema.safeParse({ user_ids: 'no' }).success).toBe(false);
expect(
budgetUpdateMembersRequestSchema.safeParse({ user_ids: [1, 2] }).success,
).toBe(true);
expect(
budgetUpdateMembersRequestSchema.safeParse({ user_ids: 'no' }).success,
).toBe(false);
});
});
describe('budgetToggleMemberPaidRequestSchema', () => {
it('requires a boolean paid', () => {
expect(budgetToggleMemberPaidRequestSchema.safeParse({ paid: true }).success).toBe(true);
expect(budgetToggleMemberPaidRequestSchema.safeParse({ paid: 'yes' }).success).toBe(false);
expect(
budgetToggleMemberPaidRequestSchema.safeParse({ paid: true }).success,
).toBe(true);
expect(
budgetToggleMemberPaidRequestSchema.safeParse({ paid: 'yes' }).success,
).toBe(false);
});
});
describe('budgetReorderItemsRequestSchema', () => {
it('requires numeric ids', () => {
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: [3, 1, 2] }).success).toBe(true);
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: ['a'] }).success).toBe(false);
expect(
budgetReorderItemsRequestSchema.safeParse({ orderedIds: [3, 1, 2] })
.success,
).toBe(true);
expect(
budgetReorderItemsRequestSchema.safeParse({ orderedIds: ['a'] }).success,
).toBe(false);
});
});
+18 -6
View File
@@ -59,7 +59,9 @@ export const budgetCreateItemRequestSchema = z.object({
note: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
});
export type BudgetCreateItemRequest = z.infer<typeof budgetCreateItemRequestSchema>;
export type BudgetCreateItemRequest = z.infer<
typeof budgetCreateItemRequestSchema
>;
/** Update accepts the same fields plus total_price changes; all optional. */
export const budgetUpdateItemRequestSchema = z.object({
@@ -71,24 +73,34 @@ export const budgetUpdateItemRequestSchema = z.object({
note: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
});
export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>;
export type BudgetUpdateItemRequest = z.infer<
typeof budgetUpdateItemRequestSchema
>;
export const budgetUpdateMembersRequestSchema = z.object({
user_ids: z.array(z.number()),
});
export type BudgetUpdateMembersRequest = z.infer<typeof budgetUpdateMembersRequestSchema>;
export type BudgetUpdateMembersRequest = z.infer<
typeof budgetUpdateMembersRequestSchema
>;
export const budgetToggleMemberPaidRequestSchema = z.object({
paid: z.boolean(),
});
export type BudgetToggleMemberPaidRequest = z.infer<typeof budgetToggleMemberPaidRequestSchema>;
export type BudgetToggleMemberPaidRequest = z.infer<
typeof budgetToggleMemberPaidRequestSchema
>;
export const budgetReorderItemsRequestSchema = z.object({
orderedIds: z.array(z.number()),
});
export type BudgetReorderItemsRequest = z.infer<typeof budgetReorderItemsRequestSchema>;
export type BudgetReorderItemsRequest = z.infer<
typeof budgetReorderItemsRequestSchema
>;
export const budgetReorderCategoriesRequestSchema = z.object({
orderedCategories: z.array(z.string()),
});
export type BudgetReorderCategoriesRequest = z.infer<typeof budgetReorderCategoriesRequestSchema>;
export type BudgetReorderCategoriesRequest = z.infer<
typeof budgetReorderCategoriesRequestSchema
>;
+19 -5
View File
@@ -1,20 +1,32 @@
import { describe, it, expect } from 'vitest';
import {
categorySchema,
createCategoryRequestSchema,
updateCategoryRequestSchema,
} from './category.schema';
import { describe, it, expect } from 'vitest';
describe('categorySchema', () => {
it('accepts a full category', () => {
expect(categorySchema.safeParse({ id: 1, name: 'Food', color: '#fff', icon: '🍔' }).success).toBe(true);
expect(
categorySchema.safeParse({
id: 1,
name: 'Food',
color: '#fff',
icon: '🍔',
}).success,
).toBe(true);
});
});
describe('createCategoryRequestSchema', () => {
it('requires a non-empty name; colour and icon are optional', () => {
expect(createCategoryRequestSchema.safeParse({ name: 'Food' }).success).toBe(true);
expect(createCategoryRequestSchema.safeParse({ name: '' }).success).toBe(false);
expect(
createCategoryRequestSchema.safeParse({ name: 'Food' }).success,
).toBe(true);
expect(createCategoryRequestSchema.safeParse({ name: '' }).success).toBe(
false,
);
expect(createCategoryRequestSchema.safeParse({}).success).toBe(false);
});
});
@@ -22,6 +34,8 @@ describe('createCategoryRequestSchema', () => {
describe('updateCategoryRequestSchema', () => {
it('allows every field to be omitted (the service COALESCEs)', () => {
expect(updateCategoryRequestSchema.safeParse({}).success).toBe(true);
expect(updateCategoryRequestSchema.safeParse({ color: '#000' }).success).toBe(true);
expect(
updateCategoryRequestSchema.safeParse({ color: '#000' }).success,
).toBe(true);
});
});
+50 -14
View File
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import {
collabNoteCreateRequestSchema,
collabPollCreateRequestSchema,
@@ -7,41 +6,78 @@ import {
collabReactionRequestSchema,
} from './collab.schema';
import { describe, it, expect } from 'vitest';
describe('collabNoteCreateRequestSchema', () => {
it('requires a non-empty title; the rest is optional', () => {
expect(collabNoteCreateRequestSchema.safeParse({ title: 'Idea' }).success).toBe(true);
expect(collabNoteCreateRequestSchema.safeParse({ title: '' }).success).toBe(false);
expect(
collabNoteCreateRequestSchema.safeParse({ title: 'Idea' }).success,
).toBe(true);
expect(collabNoteCreateRequestSchema.safeParse({ title: '' }).success).toBe(
false,
);
expect(collabNoteCreateRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('collabPollCreateRequestSchema', () => {
it('requires a question and at least two options', () => {
expect(collabPollCreateRequestSchema.safeParse({ question: 'Where?', options: ['A', 'B'] }).success).toBe(true);
expect(collabPollCreateRequestSchema.safeParse({ question: 'Where?', options: ['A'] }).success).toBe(false);
expect(collabPollCreateRequestSchema.safeParse({ options: ['A', 'B'] }).success).toBe(false);
expect(
collabPollCreateRequestSchema.safeParse({
question: 'Where?',
options: ['A', 'B'],
}).success,
).toBe(true);
expect(
collabPollCreateRequestSchema.safeParse({
question: 'Where?',
options: ['A'],
}).success,
).toBe(false);
expect(
collabPollCreateRequestSchema.safeParse({ options: ['A', 'B'] }).success,
).toBe(false);
});
});
describe('collabPollVoteRequestSchema', () => {
it('requires a numeric option_index', () => {
expect(collabPollVoteRequestSchema.safeParse({ option_index: 0 }).success).toBe(true);
expect(collabPollVoteRequestSchema.safeParse({ option_index: 'a' }).success).toBe(false);
expect(
collabPollVoteRequestSchema.safeParse({ option_index: 0 }).success,
).toBe(true);
expect(
collabPollVoteRequestSchema.safeParse({ option_index: 'a' }).success,
).toBe(false);
});
});
describe('collabMessageCreateRequestSchema', () => {
it('requires text, caps it at 5000, allows a nullable reply_to', () => {
expect(collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: null }).success).toBe(true);
expect(collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: 4 }).success).toBe(true);
expect(collabMessageCreateRequestSchema.safeParse({ text: '' }).success).toBe(false);
expect(collabMessageCreateRequestSchema.safeParse({ text: 'x'.repeat(5001) }).success).toBe(false);
expect(
collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: null })
.success,
).toBe(true);
expect(
collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: 4 })
.success,
).toBe(true);
expect(
collabMessageCreateRequestSchema.safeParse({ text: '' }).success,
).toBe(false);
expect(
collabMessageCreateRequestSchema.safeParse({ text: 'x'.repeat(5001) })
.success,
).toBe(false);
});
});
describe('collabReactionRequestSchema', () => {
it('requires a non-empty emoji', () => {
expect(collabReactionRequestSchema.safeParse({ emoji: '👍' }).success).toBe(true);
expect(collabReactionRequestSchema.safeParse({ emoji: '' }).success).toBe(false);
expect(collabReactionRequestSchema.safeParse({ emoji: '👍' }).success).toBe(
true,
);
expect(collabReactionRequestSchema.safeParse({ emoji: '' }).success).toBe(
false,
);
});
});
+12 -4
View File
@@ -18,7 +18,9 @@ export const collabNoteCreateRequestSchema = z.object({
color: z.string().optional(),
website: z.string().optional(),
});
export type CollabNoteCreateRequest = z.infer<typeof collabNoteCreateRequestSchema>;
export type CollabNoteCreateRequest = z.infer<
typeof collabNoteCreateRequestSchema
>;
export const collabNoteUpdateRequestSchema = z.object({
title: z.string().optional(),
@@ -28,7 +30,9 @@ export const collabNoteUpdateRequestSchema = z.object({
pinned: z.union([z.boolean(), z.number()]).optional(),
website: z.string().optional(),
});
export type CollabNoteUpdateRequest = z.infer<typeof collabNoteUpdateRequestSchema>;
export type CollabNoteUpdateRequest = z.infer<
typeof collabNoteUpdateRequestSchema
>;
export const collabPollCreateRequestSchema = z.object({
question: z.string().min(1),
@@ -37,7 +41,9 @@ export const collabPollCreateRequestSchema = z.object({
multiple_choice: z.boolean().optional(),
deadline: z.string().optional(),
});
export type CollabPollCreateRequest = z.infer<typeof collabPollCreateRequestSchema>;
export type CollabPollCreateRequest = z.infer<
typeof collabPollCreateRequestSchema
>;
export const collabPollVoteRequestSchema = z.object({
option_index: z.number(),
@@ -48,7 +54,9 @@ export const collabMessageCreateRequestSchema = z.object({
text: z.string().min(1).max(5000),
reply_to: z.number().nullable().optional(),
});
export type CollabMessageCreateRequest = z.infer<typeof collabMessageCreateRequestSchema>;
export type CollabMessageCreateRequest = z.infer<
typeof collabMessageCreateRequestSchema
>;
export const collabReactionRequestSchema = z.object({
emoji: z.string().min(1),
+27 -8
View File
@@ -1,30 +1,49 @@
import { describe, it, expect } from 'vitest';
import {
dayCreateRequestSchema,
dayNoteCreateRequestSchema,
dayNoteUpdateRequestSchema,
} from './day.schema';
import { describe, it, expect } from 'vitest';
describe('dayCreateRequestSchema', () => {
it('accepts an optional date + notes', () => {
expect(dayCreateRequestSchema.safeParse({}).success).toBe(true);
expect(dayCreateRequestSchema.safeParse({ date: '2026-07-01', notes: 'n' }).success).toBe(true);
expect(
dayCreateRequestSchema.safeParse({ date: '2026-07-01', notes: 'n' })
.success,
).toBe(true);
});
});
describe('dayNoteCreateRequestSchema', () => {
it('requires non-empty text capped at 500, time capped at 150', () => {
expect(dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: '' }).success).toBe(false);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', time: 'y'.repeat(151) }).success).toBe(false);
expect(
dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success,
).toBe(true);
expect(dayNoteCreateRequestSchema.safeParse({ text: '' }).success).toBe(
false,
);
expect(
dayNoteCreateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success,
).toBe(false);
expect(
dayNoteCreateRequestSchema.safeParse({
text: 'ok',
time: 'y'.repeat(151),
}).success,
).toBe(false);
});
});
describe('dayNoteUpdateRequestSchema', () => {
it('allows omitting text and caps the lengths', () => {
expect(dayNoteUpdateRequestSchema.safeParse({}).success).toBe(true);
expect(dayNoteUpdateRequestSchema.safeParse({ icon: '🍽️' }).success).toBe(true);
expect(dayNoteUpdateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
expect(dayNoteUpdateRequestSchema.safeParse({ icon: '🍽️' }).success).toBe(
true,
);
expect(
dayNoteUpdateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success,
).toBe(false);
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { assignmentSchema } from '../assignment/assignment.schema';
import { z } from 'zod';
/**
* Day + day-note API contract — single source of truth for the
* /api/trips/:tripId/days and /api/trips/:tripId/days/:dayId/notes endpoints.
+21 -5
View File
@@ -1,18 +1,34 @@
import {
fileUpdateRequestSchema,
fileLinkRequestSchema,
photoVariantSchema,
} from './file.schema';
import { describe, it, expect } from 'vitest';
import { fileUpdateRequestSchema, fileLinkRequestSchema, photoVariantSchema } from './file.schema';
describe('fileUpdateRequestSchema', () => {
it('accepts optional metadata, nullable ids, an empty body', () => {
expect(fileUpdateRequestSchema.safeParse({ description: 'doc', place_id: 3 }).success).toBe(true);
expect(fileUpdateRequestSchema.safeParse({ place_id: null, reservation_id: '7' }).success).toBe(true);
expect(
fileUpdateRequestSchema.safeParse({ description: 'doc', place_id: 3 })
.success,
).toBe(true);
expect(
fileUpdateRequestSchema.safeParse({ place_id: null, reservation_id: '7' })
.success,
).toBe(true);
expect(fileUpdateRequestSchema.safeParse({}).success).toBe(true);
});
});
describe('fileLinkRequestSchema', () => {
it('accepts any subset of reservation/assignment/place ids', () => {
expect(fileLinkRequestSchema.safeParse({ reservation_id: 1 }).success).toBe(true);
expect(fileLinkRequestSchema.safeParse({ assignment_id: '2', place_id: null }).success).toBe(true);
expect(fileLinkRequestSchema.safeParse({ reservation_id: 1 }).success).toBe(
true,
);
expect(
fileLinkRequestSchema.safeParse({ assignment_id: '2', place_id: null })
.success,
).toBe(true);
expect(fileLinkRequestSchema.safeParse({}).success).toBe(true);
});
});
+2 -1
View File
@@ -146,7 +146,8 @@ const dashboard: TranslationStrings = {
'dashboard.confirm.copy.willCopy': 'Se copiará',
'dashboard.confirm.copy.will1': 'Días, lugares y asignaciones por día',
'dashboard.confirm.copy.will2': 'Alojamientos y reservas',
'dashboard.confirm.copy.will3': 'Partidas de presupuesto y orden de categorías',
'dashboard.confirm.copy.will3':
'Partidas de presupuesto y orden de categorías',
'dashboard.confirm.copy.will4': 'Listas de equipaje (sin marcar)',
'dashboard.confirm.copy.will5': 'Tareas (sin asignar ni marcar)',
'dashboard.confirm.copy.will6': 'Notas del día',
+6 -5
View File
@@ -109,7 +109,7 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.currencyConverter': 'Convertisseur de devises',
'dashboard.filter.planned': 'Planifiés',
'dashboard.hero.badgeLive': 'EN DIRECT',
'dashboard.hero.badgeToday': 'DÉBUTE AUJOURD\'HUI',
'dashboard.hero.badgeToday': "DÉBUTE AUJOURD'HUI",
'dashboard.hero.badgeTomorrow': 'DEMAIN',
'dashboard.hero.badgeNext': 'À SUIVRE',
'dashboard.hero.badgeRecent': 'RÉCENT',
@@ -135,16 +135,17 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.acrossAllTrips': 'sur tous les voyages',
'dashboard.atlas.distanceFlown': 'Distance parcourue en avion',
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ {count}× le tour de l\'équateur',
'dashboard.atlas.aroundEquator': "≈ {count}× le tour de l'équateur",
'dashboard.card.idea': 'Idée',
'dashboard.card.buddyOne': 'Compagnon',
'dashboard.fx.from': 'De',
'dashboard.fx.to': 'Vers',
'dashboard.fx.unavailable': 'Taux indisponible',
'dashboard.tz.searchPlaceholder': 'Rechercher un fuseau horaire…',
'dashboard.tz.empty': 'Pas encore d\'autres fuseaux horaires — ajoutez-en un avec +',
'dashboard.tz.empty':
"Pas encore d'autres fuseaux horaires — ajoutez-en un avec +",
'dashboard.upcoming.title': 'Prochaines réservations',
'dashboard.upcoming.empty': 'Rien de réservé pour l\'instant.',
'dashboard.upcoming.empty': "Rien de réservé pour l'instant.",
'dashboard.confirm.copy.title': 'Copier ce voyage ?',
'dashboard.confirm.copy.willCopy': 'Sera copié',
'dashboard.confirm.copy.will1': 'Jours, lieux et affectations par jour',
@@ -159,7 +160,7 @@ const dashboard: TranslationStrings = {
'dashboard.confirm.copy.wont3': 'Fichiers et photos',
'dashboard.confirm.copy.wont4': 'Jetons de partage',
'dashboard.confirm.copy.confirm': 'Copier le voyage',
'dashboard.aria.toggleView': 'Changer d\'affichage',
'dashboard.aria.toggleView': "Changer d'affichage",
'dashboard.aria.filter': 'Filtrer',
'dashboard.aria.duplicate': 'Dupliquer',
'dashboard.aria.refreshRates': 'Actualiser les taux',
+2 -1
View File
@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest';
// @ts-expect-error — plain .mjs script with no .d.ts; import as JS module.
import { checkParity } from '../../scripts/i18n-parity.mjs';
import { describe, it, expect } from 'vitest';
/**
* Enforces the file-set contract for the i18n migration: every non-en locale
* dir must contain the exact same domain files as en/.
+4 -3
View File
@@ -124,7 +124,7 @@ const dashboard: TranslationStrings = {
'dashboard.hero.dayLeft': 'Giorno rimasto',
'dashboard.hero.daysLeft': 'Giorni rimasti',
'dashboard.hero.lastDay': 'Ultimo giorno',
'dashboard.hero.untilStart': 'All\'inizio',
'dashboard.hero.untilStart': "All'inizio",
'dashboard.hero.startsIn': 'Si parte tra',
'dashboard.atlas.countriesVisited': 'Atlas · Paesi visitati',
'dashboard.atlas.ofTotal': 'di {total}',
@@ -135,14 +135,15 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.acrossAllTrips': 'su tutti i viaggi',
'dashboard.atlas.distanceFlown': 'Distanza in volo',
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ {count}× intorno all\'equatore',
'dashboard.atlas.aroundEquator': "≈ {count}× intorno all'equatore",
'dashboard.card.idea': 'Idea',
'dashboard.card.buddyOne': 'Compagno',
'dashboard.fx.from': 'Da',
'dashboard.fx.to': 'A',
'dashboard.fx.unavailable': 'Tasso non disponibile',
'dashboard.tz.searchPlaceholder': 'Cerca fuso orario…',
'dashboard.tz.empty': 'Ancora nessun altro fuso orario — aggiungine uno con +',
'dashboard.tz.empty':
'Ancora nessun altro fuso orario — aggiungine uno con +',
'dashboard.upcoming.title': 'Prossime prenotazioni',
'dashboard.upcoming.empty': 'Niente ancora prenotato.',
'dashboard.confirm.copy.title': 'Copiare questo viaggio?',
+2 -2
View File
@@ -153,14 +153,14 @@ const dashboard: TranslationStrings = {
'dashboard.confirm.copy.wontCopy': 'Wordt niet gekopieerd',
'dashboard.confirm.copy.wont1': 'Medewerkers & ledentoewijzingen',
'dashboard.confirm.copy.wont2': 'Gedeelde notities, peilingen & berichten',
'dashboard.confirm.copy.wont3': 'Bestanden & foto\'s',
'dashboard.confirm.copy.wont3': "Bestanden & foto's",
'dashboard.confirm.copy.wont4': 'Deeltokens',
'dashboard.confirm.copy.confirm': 'Reis kopiëren',
'dashboard.aria.toggleView': 'Weergave wisselen',
'dashboard.aria.filter': 'Filter',
'dashboard.aria.duplicate': 'Dupliceren',
'dashboard.aria.refreshRates': 'Koersen vernieuwen',
'dashboard.aria.swapCurrencies': 'Valuta\'s omwisselen',
'dashboard.aria.swapCurrencies': "Valuta's omwisselen",
'dashboard.aria.addTimezone': 'Tijdzone toevoegen',
'dashboard.aria.removeTimezone': '{city} verwijderen',
};
+2 -1
View File
@@ -153,7 +153,8 @@ const dashboard: TranslationStrings = {
'dashboard.fx.to': 'У',
'dashboard.fx.unavailable': 'Курс недоступний',
'dashboard.tz.searchPlaceholder': 'Пошук часового поясу…',
'dashboard.tz.empty': 'Інших часових поясів поки немає — додайте за допомогою +',
'dashboard.tz.empty':
'Інших часових поясів поки немає — додайте за допомогою +',
'dashboard.upcoming.title': 'Найближчі бронювання',
'dashboard.upcoming.empty': 'Поки нічого не заброньовано.',
'dashboard.aria.toggleView': 'Перемкнути вигляд',
+58 -15
View File
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import {
journeyCreateRequestSchema,
journeyAddTripRequestSchema,
@@ -8,48 +7,92 @@ import {
journeyShareLinkRequestSchema,
} from './journey.schema';
import { describe, it, expect } from 'vitest';
describe('journeyCreateRequestSchema', () => {
it('requires a title; subtitle + trip_ids optional', () => {
expect(journeyCreateRequestSchema.safeParse({ title: 'Trip of a lifetime' }).success).toBe(true);
expect(journeyCreateRequestSchema.safeParse({ title: 'X', trip_ids: [1, '2'] }).success).toBe(true);
expect(journeyCreateRequestSchema.safeParse({ subtitle: 'no title' }).success).toBe(false);
expect(
journeyCreateRequestSchema.safeParse({ title: 'Trip of a lifetime' })
.success,
).toBe(true);
expect(
journeyCreateRequestSchema.safeParse({ title: 'X', trip_ids: [1, '2'] })
.success,
).toBe(true);
expect(
journeyCreateRequestSchema.safeParse({ subtitle: 'no title' }).success,
).toBe(false);
});
});
describe('journeyAddTripRequestSchema', () => {
it('requires a trip_id (string or number)', () => {
expect(journeyAddTripRequestSchema.safeParse({ trip_id: 5 }).success).toBe(true);
expect(journeyAddTripRequestSchema.safeParse({ trip_id: '5' }).success).toBe(true);
expect(journeyAddTripRequestSchema.safeParse({ trip_id: 5 }).success).toBe(
true,
);
expect(
journeyAddTripRequestSchema.safeParse({ trip_id: '5' }).success,
).toBe(true);
expect(journeyAddTripRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('journeyReorderEntriesRequestSchema', () => {
it('requires a non-empty orderedIds array', () => {
expect(journeyReorderEntriesRequestSchema.safeParse({ orderedIds: [3, 1, 2] }).success).toBe(true);
expect(journeyReorderEntriesRequestSchema.safeParse({ orderedIds: [] }).success).toBe(false);
expect(
journeyReorderEntriesRequestSchema.safeParse({ orderedIds: [3, 1, 2] })
.success,
).toBe(true);
expect(
journeyReorderEntriesRequestSchema.safeParse({ orderedIds: [] }).success,
).toBe(false);
});
});
describe('journeyContributorRequestSchema', () => {
it('requires user_id; role limited to editor/viewer', () => {
expect(journeyContributorRequestSchema.safeParse({ user_id: 2 }).success).toBe(true);
expect(journeyContributorRequestSchema.safeParse({ user_id: 2, role: 'editor' }).success).toBe(true);
expect(journeyContributorRequestSchema.safeParse({ user_id: 2, role: 'admin' }).success).toBe(false);
expect(
journeyContributorRequestSchema.safeParse({ user_id: 2 }).success,
).toBe(true);
expect(
journeyContributorRequestSchema.safeParse({ user_id: 2, role: 'editor' })
.success,
).toBe(true);
expect(
journeyContributorRequestSchema.safeParse({ user_id: 2, role: 'admin' })
.success,
).toBe(false);
});
});
describe('journeyProviderPhotosRequestSchema', () => {
it('requires a provider; accepts single asset_id or a batch', () => {
expect(journeyProviderPhotosRequestSchema.safeParse({ provider: 'immich', asset_id: 'a1' }).success).toBe(true);
expect(journeyProviderPhotosRequestSchema.safeParse({ provider: 'immich', asset_ids: ['a1', 'a2'] }).success).toBe(true);
expect(journeyProviderPhotosRequestSchema.safeParse({ asset_id: 'a1' }).success).toBe(false);
expect(
journeyProviderPhotosRequestSchema.safeParse({
provider: 'immich',
asset_id: 'a1',
}).success,
).toBe(true);
expect(
journeyProviderPhotosRequestSchema.safeParse({
provider: 'immich',
asset_ids: ['a1', 'a2'],
}).success,
).toBe(true);
expect(
journeyProviderPhotosRequestSchema.safeParse({ asset_id: 'a1' }).success,
).toBe(false);
});
});
describe('journeyShareLinkRequestSchema', () => {
it('accepts optional share toggles', () => {
expect(journeyShareLinkRequestSchema.safeParse({ share_timeline: true, share_gallery: false }).success).toBe(true);
expect(
journeyShareLinkRequestSchema.safeParse({
share_timeline: true,
share_gallery: false,
}).success,
).toBe(true);
expect(journeyShareLinkRequestSchema.safeParse({}).success).toBe(true);
});
});
+12 -4
View File
@@ -28,13 +28,17 @@ export type JourneyAddTripRequest = z.infer<typeof journeyAddTripRequestSchema>;
export const journeyReorderEntriesRequestSchema = z.object({
orderedIds: z.array(z.union([z.string(), z.number()])).min(1),
});
export type JourneyReorderEntriesRequest = z.infer<typeof journeyReorderEntriesRequestSchema>;
export type JourneyReorderEntriesRequest = z.infer<
typeof journeyReorderEntriesRequestSchema
>;
export const journeyContributorRequestSchema = z.object({
user_id: z.union([z.string(), z.number()]),
role: z.enum(['editor', 'viewer']).optional(),
});
export type JourneyContributorRequest = z.infer<typeof journeyContributorRequestSchema>;
export type JourneyContributorRequest = z.infer<
typeof journeyContributorRequestSchema
>;
export const journeyProviderPhotosRequestSchema = z.object({
provider: z.string().min(1),
@@ -43,11 +47,15 @@ export const journeyProviderPhotosRequestSchema = z.object({
caption: z.string().optional(),
passphrase: z.string().optional(),
});
export type JourneyProviderPhotosRequest = z.infer<typeof journeyProviderPhotosRequestSchema>;
export type JourneyProviderPhotosRequest = z.infer<
typeof journeyProviderPhotosRequestSchema
>;
export const journeyShareLinkRequestSchema = z.object({
share_timeline: z.boolean().optional(),
share_gallery: z.boolean().optional(),
share_map: z.boolean().optional(),
});
export type JourneyShareLinkRequest = z.infer<typeof journeyShareLinkRequestSchema>;
export type JourneyShareLinkRequest = z.infer<
typeof journeyShareLinkRequestSchema
>;
+35 -12
View File
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import {
mapsSearchRequestSchema,
mapsAutocompleteRequestSchema,
@@ -6,34 +5,58 @@ import {
mapsResolveUrlRequestSchema,
} from './maps.schema';
import { describe, it, expect } from 'vitest';
describe('mapsSearchRequestSchema', () => {
it('requires a non-empty query', () => {
expect(mapsSearchRequestSchema.safeParse({ query: 'berlin' }).success).toBe(true);
expect(mapsSearchRequestSchema.safeParse({ query: '' }).success).toBe(false);
expect(mapsSearchRequestSchema.safeParse({ query: 'berlin' }).success).toBe(
true,
);
expect(mapsSearchRequestSchema.safeParse({ query: '' }).success).toBe(
false,
);
expect(mapsSearchRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('mapsAutocompleteRequestSchema', () => {
it('caps input at 200 chars and allows an optional locationBias', () => {
expect(mapsAutocompleteRequestSchema.safeParse({ input: 'be' }).success).toBe(true);
expect(mapsAutocompleteRequestSchema.safeParse({ input: 'x'.repeat(201) }).success).toBe(false);
expect(mapsAutocompleteRequestSchema.safeParse({
input: 'be', locationBias: { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } },
}).success).toBe(true);
expect(
mapsAutocompleteRequestSchema.safeParse({ input: 'be' }).success,
).toBe(true);
expect(
mapsAutocompleteRequestSchema.safeParse({ input: 'x'.repeat(201) })
.success,
).toBe(false);
expect(
mapsAutocompleteRequestSchema.safeParse({
input: 'be',
locationBias: { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } },
}).success,
).toBe(true);
});
});
describe('mapsReverseQuerySchema', () => {
it('requires lat and lng as strings (the route parses them downstream)', () => {
expect(mapsReverseQuerySchema.safeParse({ lat: '52.5', lng: '13.4' }).success).toBe(true);
expect(mapsReverseQuerySchema.safeParse({ lat: '52.5' }).success).toBe(false);
expect(
mapsReverseQuerySchema.safeParse({ lat: '52.5', lng: '13.4' }).success,
).toBe(true);
expect(mapsReverseQuerySchema.safeParse({ lat: '52.5' }).success).toBe(
false,
);
});
});
describe('mapsResolveUrlRequestSchema', () => {
it('requires a non-empty url', () => {
expect(mapsResolveUrlRequestSchema.safeParse({ url: 'https://maps.app.goo.gl/x' }).success).toBe(true);
expect(mapsResolveUrlRequestSchema.safeParse({ url: '' }).success).toBe(false);
expect(
mapsResolveUrlRequestSchema.safeParse({
url: 'https://maps.app.goo.gl/x',
}).success,
).toBe(true);
expect(mapsResolveUrlRequestSchema.safeParse({ url: '' }).success).toBe(
false,
);
});
});
+9 -3
View File
@@ -27,7 +27,9 @@ export const mapsAutocompleteRequestSchema = z.object({
lang: z.string().optional(),
locationBias: z.object({ low: latLng, high: latLng }).optional(),
});
export type MapsAutocompleteRequest = z.infer<typeof mapsAutocompleteRequestSchema>;
export type MapsAutocompleteRequest = z.infer<
typeof mapsAutocompleteRequestSchema
>;
export const mapsReverseQuerySchema = z.object({
lat: z.string().min(1),
@@ -59,13 +61,17 @@ export const mapsAutocompleteResultSchema = z.object({
suggestions: z.array(mapsAutocompleteSuggestionSchema),
source: z.string(),
});
export type MapsAutocompleteResult = z.infer<typeof mapsAutocompleteResultSchema>;
export type MapsAutocompleteResult = z.infer<
typeof mapsAutocompleteResultSchema
>;
export const mapsPlaceDetailsResultSchema = z.object({
place: placeRecord.nullable(),
disabled: z.boolean().optional(),
});
export type MapsPlaceDetailsResult = z.infer<typeof mapsPlaceDetailsResultSchema>;
export type MapsPlaceDetailsResult = z.infer<
typeof mapsPlaceDetailsResultSchema
>;
export const mapsPlacePhotoResultSchema = z.object({
photoUrl: z.string().nullable(),
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import {
preferencesUpdateRequestSchema,
notificationRespondRequestSchema,
@@ -6,31 +5,55 @@ import {
inAppListResultSchema,
} from './notification.schema';
import { describe, it, expect } from 'vitest';
describe('preferencesUpdateRequestSchema', () => {
it('accepts a nested event/channel/enabled matrix', () => {
expect(preferencesUpdateRequestSchema.safeParse({ trip_invite: { inapp: true, email: false } }).success).toBe(true);
expect(preferencesUpdateRequestSchema.safeParse({ trip_invite: { inapp: 'yes' } }).success).toBe(false);
expect(
preferencesUpdateRequestSchema.safeParse({
trip_invite: { inapp: true, email: false },
}).success,
).toBe(true);
expect(
preferencesUpdateRequestSchema.safeParse({
trip_invite: { inapp: 'yes' },
}).success,
).toBe(false);
});
});
describe('notificationRespondRequestSchema', () => {
it('only accepts positive/negative', () => {
expect(notificationRespondRequestSchema.safeParse({ response: 'positive' }).success).toBe(true);
expect(notificationRespondRequestSchema.safeParse({ response: 'maybe' }).success).toBe(false);
expect(
notificationRespondRequestSchema.safeParse({ response: 'positive' })
.success,
).toBe(true);
expect(
notificationRespondRequestSchema.safeParse({ response: 'maybe' }).success,
).toBe(false);
});
});
describe('channelTestResultSchema', () => {
it('accepts a success result and an error result', () => {
expect(channelTestResultSchema.safeParse({ success: true }).success).toBe(true);
expect(channelTestResultSchema.safeParse({ success: false, error: 'SMTP down' }).success).toBe(true);
expect(channelTestResultSchema.safeParse({ success: true }).success).toBe(
true,
);
expect(
channelTestResultSchema.safeParse({ success: false, error: 'SMTP down' })
.success,
).toBe(true);
});
});
describe('inAppListResultSchema', () => {
it('accepts the list envelope with open notification rows', () => {
expect(inAppListResultSchema.safeParse({
notifications: [{ id: 1, type: 'info', anything: 'goes' }], total: 1, unread_count: 0,
}).success).toBe(true);
expect(
inAppListResultSchema.safeParse({
notifications: [{ id: 1, type: 'info', anything: 'goes' }],
total: 1,
unread_count: 0,
}).success,
).toBe(true);
});
});
@@ -18,10 +18,14 @@ export const preferencesUpdateRequestSchema = z.record(
z.string(),
z.record(z.string(), z.boolean()),
);
export type PreferencesUpdateRequest = z.infer<typeof preferencesUpdateRequestSchema>;
export type PreferencesUpdateRequest = z.infer<
typeof preferencesUpdateRequestSchema
>;
export const testSmtpRequestSchema = z.object({ email: z.string().optional() });
export const testWebhookRequestSchema = z.object({ url: z.string().optional() });
export const testWebhookRequestSchema = z.object({
url: z.string().optional(),
});
export const testNtfyRequestSchema = z.object({
topic: z.string().optional(),
server: z.string().optional(),
@@ -39,7 +43,9 @@ export type ChannelTestResult = z.infer<typeof channelTestResultSchema>;
export const notificationRespondRequestSchema = z.object({
response: z.enum(['positive', 'negative']),
});
export type NotificationRespondRequest = z.infer<typeof notificationRespondRequestSchema>;
export type NotificationRespondRequest = z.infer<
typeof notificationRespondRequestSchema
>;
/** A single in-app notification row (DB-shaped; kept open). */
export const notificationRowSchema = z.record(z.string(), z.unknown());
+54 -8
View File
@@ -1,25 +1,71 @@
import {
oauthTokenRequestSchema,
oauthConsentRequestSchema,
oauthClientCreateRequestSchema,
} from './oauth.schema';
import { describe, it, expect } from 'vitest';
import { oauthTokenRequestSchema, oauthConsentRequestSchema, oauthClientCreateRequestSchema } from './oauth.schema';
describe('oauthTokenRequestSchema', () => {
it('is permissive across grant types and passes extras through', () => {
expect(oauthTokenRequestSchema.safeParse({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }).success).toBe(true);
expect(oauthTokenRequestSchema.safeParse({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a b' }).success).toBe(true);
expect(
oauthTokenRequestSchema.safeParse({
grant_type: 'authorization_code',
client_id: 'c',
code: 'x',
redirect_uri: 'u',
code_verifier: 'v',
}).success,
).toBe(true);
expect(
oauthTokenRequestSchema.safeParse({
grant_type: 'client_credentials',
client_id: 'c',
client_secret: 's',
scope: 'a b',
}).success,
).toBe(true);
expect(oauthTokenRequestSchema.safeParse({}).success).toBe(true);
});
});
describe('oauthConsentRequestSchema', () => {
it('requires the PKCE consent fields + approved flag', () => {
expect(oauthConsentRequestSchema.safeParse({ client_id: 'c', redirect_uri: 'u', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }).success).toBe(true);
expect(oauthConsentRequestSchema.safeParse({ client_id: 'c', redirect_uri: 'u', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256' }).success).toBe(false);
expect(
oauthConsentRequestSchema.safeParse({
client_id: 'c',
redirect_uri: 'u',
scope: 's',
code_challenge: 'cc',
code_challenge_method: 'S256',
approved: true,
}).success,
).toBe(true);
expect(
oauthConsentRequestSchema.safeParse({
client_id: 'c',
redirect_uri: 'u',
scope: 's',
code_challenge: 'cc',
code_challenge_method: 'S256',
}).success,
).toBe(false);
});
});
describe('oauthClientCreateRequestSchema', () => {
it('requires name + allowed_scopes', () => {
expect(oauthClientCreateRequestSchema.safeParse({ name: 'CLI', allowed_scopes: ['trips:read'] }).success).toBe(true);
expect(oauthClientCreateRequestSchema.safeParse({ name: 'CLI' }).success).toBe(false);
expect(oauthClientCreateRequestSchema.safeParse({ allowed_scopes: [] }).success).toBe(false);
expect(
oauthClientCreateRequestSchema.safeParse({
name: 'CLI',
allowed_scopes: ['trips:read'],
}).success,
).toBe(true);
expect(
oauthClientCreateRequestSchema.safeParse({ name: 'CLI' }).success,
).toBe(false);
expect(
oauthClientCreateRequestSchema.safeParse({ allowed_scopes: [] }).success,
).toBe(false);
});
});
+3 -1
View File
@@ -41,4 +41,6 @@ export const oauthClientCreateRequestSchema = z.object({
allowed_scopes: z.array(z.string()),
allows_client_credentials: z.boolean().optional(),
});
export type OauthClientCreateRequest = z.infer<typeof oauthClientCreateRequestSchema>;
export type OauthClientCreateRequest = z.infer<
typeof oauthClientCreateRequestSchema
>;
+11 -3
View File
@@ -1,10 +1,18 @@
import {
oidcCallbackQuerySchema,
oidcExchangeQuerySchema,
} from './oidc.schema';
import { describe, it, expect } from 'vitest';
import { oidcCallbackQuerySchema, oidcExchangeQuerySchema } from './oidc.schema';
describe('oidcCallbackQuerySchema', () => {
it('accepts code+state, an error, or nothing (all optional)', () => {
expect(oidcCallbackQuerySchema.safeParse({ code: 'c', state: 's' }).success).toBe(true);
expect(oidcCallbackQuerySchema.safeParse({ error: 'access_denied' }).success).toBe(true);
expect(
oidcCallbackQuerySchema.safeParse({ code: 'c', state: 's' }).success,
).toBe(true);
expect(
oidcCallbackQuerySchema.safeParse({ error: 'access_denied' }).success,
).toBe(true);
expect(oidcCallbackQuerySchema.safeParse({}).success).toBe(true);
});
});
+29 -8
View File
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import {
packingCreateItemRequestSchema,
packingImportRequestSchema,
@@ -6,30 +5,52 @@ import {
packingSaveTemplateRequestSchema,
} from './packing.schema';
import { describe, it, expect } from 'vitest';
describe('packingCreateItemRequestSchema', () => {
it('requires a non-empty name; category/checked optional', () => {
expect(packingCreateItemRequestSchema.safeParse({ name: 'Socks' }).success).toBe(true);
expect(packingCreateItemRequestSchema.safeParse({ name: 'Socks', category: 'Clothes', checked: true }).success).toBe(true);
expect(packingCreateItemRequestSchema.safeParse({ name: '' }).success).toBe(false);
expect(
packingCreateItemRequestSchema.safeParse({ name: 'Socks' }).success,
).toBe(true);
expect(
packingCreateItemRequestSchema.safeParse({
name: 'Socks',
category: 'Clothes',
checked: true,
}).success,
).toBe(true);
expect(packingCreateItemRequestSchema.safeParse({ name: '' }).success).toBe(
false,
);
});
});
describe('packingImportRequestSchema', () => {
it('accepts an array of open item rows', () => {
expect(packingImportRequestSchema.safeParse({ items: [{ name: 'a' }, { name: 'b', anything: 1 }] }).success).toBe(true);
expect(
packingImportRequestSchema.safeParse({
items: [{ name: 'a' }, { name: 'b', anything: 1 }],
}).success,
).toBe(true);
});
});
describe('packingCreateBagRequestSchema', () => {
it('requires a name', () => {
expect(packingCreateBagRequestSchema.safeParse({ name: 'Carry-on' }).success).toBe(true);
expect(
packingCreateBagRequestSchema.safeParse({ name: 'Carry-on' }).success,
).toBe(true);
expect(packingCreateBagRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('packingSaveTemplateRequestSchema', () => {
it('requires a name', () => {
expect(packingSaveTemplateRequestSchema.safeParse({ name: 'Summer' }).success).toBe(true);
expect(packingSaveTemplateRequestSchema.safeParse({ name: '' }).success).toBe(false);
expect(
packingSaveTemplateRequestSchema.safeParse({ name: 'Summer' }).success,
).toBe(true);
expect(
packingSaveTemplateRequestSchema.safeParse({ name: '' }).success,
).toBe(false);
});
});
+21 -7
View File
@@ -68,7 +68,9 @@ export const packingCreateItemRequestSchema = z.object({
category: z.string().optional(),
checked: z.boolean().optional(),
});
export type PackingCreateItemRequest = z.infer<typeof packingCreateItemRequestSchema>;
export type PackingCreateItemRequest = z.infer<
typeof packingCreateItemRequestSchema
>;
export const packingUpdateItemRequestSchema = z.object({
name: z.string().optional(),
@@ -78,7 +80,9 @@ export const packingUpdateItemRequestSchema = z.object({
bag_id: z.number().nullable().optional(),
quantity: z.number().optional(),
});
export type PackingUpdateItemRequest = z.infer<typeof packingUpdateItemRequestSchema>;
export type PackingUpdateItemRequest = z.infer<
typeof packingUpdateItemRequestSchema
>;
export const packingImportRequestSchema = z.object({
items: z.array(open),
@@ -94,7 +98,9 @@ export const packingCreateBagRequestSchema = z.object({
name: z.string().min(1),
color: z.string().optional(),
});
export type PackingCreateBagRequest = z.infer<typeof packingCreateBagRequestSchema>;
export type PackingCreateBagRequest = z.infer<
typeof packingCreateBagRequestSchema
>;
export const packingUpdateBagRequestSchema = z.object({
name: z.string().optional(),
@@ -102,19 +108,27 @@ export const packingUpdateBagRequestSchema = z.object({
weight_limit_grams: z.number().nullable().optional(),
user_id: z.number().nullable().optional(),
});
export type PackingUpdateBagRequest = z.infer<typeof packingUpdateBagRequestSchema>;
export type PackingUpdateBagRequest = z.infer<
typeof packingUpdateBagRequestSchema
>;
export const packingBagMembersRequestSchema = z.object({
user_ids: z.array(z.number()),
});
export type PackingBagMembersRequest = z.infer<typeof packingBagMembersRequestSchema>;
export type PackingBagMembersRequest = z.infer<
typeof packingBagMembersRequestSchema
>;
export const packingSaveTemplateRequestSchema = z.object({
name: z.string().min(1),
});
export type PackingSaveTemplateRequest = z.infer<typeof packingSaveTemplateRequestSchema>;
export type PackingSaveTemplateRequest = z.infer<
typeof packingSaveTemplateRequestSchema
>;
export const packingCategoryAssigneesRequestSchema = z.object({
user_ids: z.array(z.number()),
});
export type PackingCategoryAssigneesRequest = z.infer<typeof packingCategoryAssigneesRequestSchema>;
export type PackingCategoryAssigneesRequest = z.infer<
typeof packingCategoryAssigneesRequestSchema
>;
+22 -6
View File
@@ -1,27 +1,43 @@
import { describe, it, expect } from 'vitest';
import {
placeCreateRequestSchema,
placeBulkDeleteRequestSchema,
placeImportListRequestSchema,
} from './place.schema';
import { describe, it, expect } from 'vitest';
describe('placeCreateRequestSchema', () => {
it('requires a name and keeps the other place fields open', () => {
expect(placeCreateRequestSchema.safeParse({ name: 'Spot', lat: 1, lng: 2, anything: true }).success).toBe(true);
expect(
placeCreateRequestSchema.safeParse({
name: 'Spot',
lat: 1,
lng: 2,
anything: true,
}).success,
).toBe(true);
expect(placeCreateRequestSchema.safeParse({ lat: 1 }).success).toBe(false);
});
});
describe('placeBulkDeleteRequestSchema', () => {
it('requires a numeric ids array', () => {
expect(placeBulkDeleteRequestSchema.safeParse({ ids: [1, 2] }).success).toBe(true);
expect(placeBulkDeleteRequestSchema.safeParse({ ids: ['a'] }).success).toBe(false);
expect(
placeBulkDeleteRequestSchema.safeParse({ ids: [1, 2] }).success,
).toBe(true);
expect(placeBulkDeleteRequestSchema.safeParse({ ids: ['a'] }).success).toBe(
false,
);
});
});
describe('placeImportListRequestSchema', () => {
it('requires a non-empty url', () => {
expect(placeImportListRequestSchema.safeParse({ url: 'http://x' }).success).toBe(true);
expect(placeImportListRequestSchema.safeParse({ url: '' }).success).toBe(false);
expect(
placeImportListRequestSchema.safeParse({ url: 'http://x' }).success,
).toBe(true);
expect(placeImportListRequestSchema.safeParse({ url: '' }).success).toBe(
false,
);
});
});
+11 -5
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { categorySchema } from '../category/category.schema';
import { tagSchema } from '../tag/tag.schema';
import { z } from 'zod';
/**
* Place API contract — single source of truth for the /api/trips/:tripId/places
* endpoints (place pool CRUD, GPX/map/list imports, image search, bulk delete).
@@ -100,7 +100,9 @@ export const assignmentPlaceSchema = z.object({
});
export type AssignmentPlace = z.infer<typeof assignmentPlaceSchema>;
export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) }));
export const placeCreateRequestSchema = open.and(
z.object({ name: z.string().min(1) }),
);
export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>;
export const placeUpdateRequestSchema = open;
@@ -109,12 +111,16 @@ export type PlaceUpdateRequest = z.infer<typeof placeUpdateRequestSchema>;
export const placeBulkDeleteRequestSchema = z.object({
ids: z.array(z.number()),
});
export type PlaceBulkDeleteRequest = z.infer<typeof placeBulkDeleteRequestSchema>;
export type PlaceBulkDeleteRequest = z.infer<
typeof placeBulkDeleteRequestSchema
>;
export const placeImportListRequestSchema = z.object({
url: z.string().min(1),
});
export type PlaceImportListRequest = z.infer<typeof placeImportListRequestSchema>;
export type PlaceImportListRequest = z.infer<
typeof placeImportListRequestSchema
>;
/** Query filters for the place list. */
export const placeListQuerySchema = z.object({
@@ -1,27 +1,52 @@
import { describe, it, expect } from 'vitest';
import {
reservationCreateRequestSchema,
reservationPositionsRequestSchema,
accommodationCreateRequestSchema,
} from './reservation.schema';
import { describe, it, expect } from 'vitest';
describe('reservationCreateRequestSchema', () => {
it('requires a title and keeps the other booking fields open', () => {
expect(reservationCreateRequestSchema.safeParse({ title: 'Hotel', anything: 1, metadata: {} }).success).toBe(true);
expect(reservationCreateRequestSchema.safeParse({ location: 'x' }).success).toBe(false);
expect(
reservationCreateRequestSchema.safeParse({
title: 'Hotel',
anything: 1,
metadata: {},
}).success,
).toBe(true);
expect(
reservationCreateRequestSchema.safeParse({ location: 'x' }).success,
).toBe(false);
});
});
describe('reservationPositionsRequestSchema', () => {
it('requires positions with id + day_plan_position', () => {
expect(reservationPositionsRequestSchema.safeParse({ positions: [{ id: 1, day_plan_position: 0 }], day_id: 3 }).success).toBe(true);
expect(reservationPositionsRequestSchema.safeParse({ positions: [{ id: 1 }] }).success).toBe(false);
expect(
reservationPositionsRequestSchema.safeParse({
positions: [{ id: 1, day_plan_position: 0 }],
day_id: 3,
}).success,
).toBe(true);
expect(
reservationPositionsRequestSchema.safeParse({ positions: [{ id: 1 }] })
.success,
).toBe(false);
});
});
describe('accommodationCreateRequestSchema', () => {
it('requires place + start/end day; check-in/out optional', () => {
expect(accommodationCreateRequestSchema.safeParse({ place_id: 2, start_day_id: 10, end_day_id: 11 }).success).toBe(true);
expect(accommodationCreateRequestSchema.safeParse({ place_id: 2 }).success).toBe(false);
expect(
accommodationCreateRequestSchema.safeParse({
place_id: 2,
start_day_id: 10,
end_day_id: 11,
}).success,
).toBe(true);
expect(
accommodationCreateRequestSchema.safeParse({ place_id: 2 }).success,
).toBe(false);
});
});
+21 -7
View File
@@ -101,17 +101,27 @@ export const accommodationSchema = z.object({
export type Accommodation = z.infer<typeof accommodationSchema>;
/** Reservation create: title is required; the many optional fields stay open. */
export const reservationCreateRequestSchema = open.and(z.object({ title: z.string().min(1) }));
export type ReservationCreateRequest = z.infer<typeof reservationCreateRequestSchema>;
export const reservationCreateRequestSchema = open.and(
z.object({ title: z.string().min(1) }),
);
export type ReservationCreateRequest = z.infer<
typeof reservationCreateRequestSchema
>;
export const reservationUpdateRequestSchema = open;
export type ReservationUpdateRequest = z.infer<typeof reservationUpdateRequestSchema>;
export type ReservationUpdateRequest = z.infer<
typeof reservationUpdateRequestSchema
>;
export const reservationPositionsRequestSchema = z.object({
positions: z.array(z.object({ id: z.number(), day_plan_position: z.number() })),
positions: z.array(
z.object({ id: z.number(), day_plan_position: z.number() }),
),
day_id: z.union([z.number(), z.string()]).nullable().optional(),
});
export type ReservationPositionsRequest = z.infer<typeof reservationPositionsRequestSchema>;
export type ReservationPositionsRequest = z.infer<
typeof reservationPositionsRequestSchema
>;
export const accommodationCreateRequestSchema = z.object({
place_id: z.union([z.number(), z.string()]),
@@ -123,7 +133,11 @@ export const accommodationCreateRequestSchema = z.object({
confirmation: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
});
export type AccommodationCreateRequest = z.infer<typeof accommodationCreateRequestSchema>;
export type AccommodationCreateRequest = z.infer<
typeof accommodationCreateRequestSchema
>;
export const accommodationUpdateRequestSchema = open;
export type AccommodationUpdateRequest = z.infer<typeof accommodationUpdateRequestSchema>;
export type AccommodationUpdateRequest = z.infer<
typeof accommodationUpdateRequestSchema
>;
+93 -72
View File
@@ -1,118 +1,139 @@
import { describe, it, expect } from 'vitest'
import { sanitizeInlineHtml, sanitizeRichTextHtml, escapeHtml } from './sanitize'
import {
sanitizeInlineHtml,
sanitizeRichTextHtml,
escapeHtml,
} from './sanitize';
import { describe, it, expect } from 'vitest';
describe('escapeHtml', () => {
it('escapes the five metacharacters', () => {
expect(escapeHtml(`a & b < c > d " e ' f`)).toBe('a &amp; b &lt; c &gt; d &quot; e &#39; f')
})
expect(escapeHtml(`a & b < c > d " e ' f`)).toBe(
'a &amp; b &lt; c &gt; d &quot; e &#39; f',
);
});
it('escapes ampersands first (no double-escape of entities)', () => {
expect(escapeHtml('&lt;')).toBe('&amp;lt;')
})
expect(escapeHtml('&lt;')).toBe('&amp;lt;');
});
it('returns empty string for empty input', () => {
expect(escapeHtml('')).toBe('')
})
expect(escapeHtml('')).toBe('');
});
it('leaves plain ASCII text untouched', () => {
expect(escapeHtml('Paris Adventure 2026')).toBe('Paris Adventure 2026')
})
expect(escapeHtml('Paris Adventure 2026')).toBe('Paris Adventure 2026');
});
it('neutralises a script tag without sanitising', () => {
expect(escapeHtml('<script>alert(1)</script>')).toBe('&lt;script&gt;alert(1)&lt;/script&gt;')
})
})
expect(escapeHtml('<script>alert(1)</script>')).toBe(
'&lt;script&gt;alert(1)&lt;/script&gt;',
);
});
});
describe('sanitizeInlineHtml', () => {
it('returns empty string for empty input', () => {
expect(sanitizeInlineHtml('')).toBe('')
})
expect(sanitizeInlineHtml('')).toBe('');
});
it('preserves the allowed inline tags', () => {
expect(sanitizeInlineHtml('a <strong>b</strong> c')).toBe('a <strong>b</strong> c')
expect(sanitizeInlineHtml('<em>x</em>')).toBe('<em>x</em>')
})
expect(sanitizeInlineHtml('a <strong>b</strong> c')).toBe(
'a <strong>b</strong> c',
);
expect(sanitizeInlineHtml('<em>x</em>')).toBe('<em>x</em>');
});
it('strips <script> entirely', () => {
const out = sanitizeInlineHtml('safe <script>alert(1)</script> text')
expect(out).not.toContain('<script')
expect(out).not.toContain('alert(1)')
expect(out).toContain('safe')
})
const out = sanitizeInlineHtml('safe <script>alert(1)</script> text');
expect(out).not.toContain('<script');
expect(out).not.toContain('alert(1)');
expect(out).toContain('safe');
});
it('strips <img> (no img tag in inline allow-list)', () => {
expect(sanitizeInlineHtml('<img src=x onerror=alert(1)>')).toBe('')
})
expect(sanitizeInlineHtml('<img src=x onerror=alert(1)>')).toBe('');
});
it('strips on* event handlers from preserved tags', () => {
const out = sanitizeInlineHtml('<span onclick="alert(1)">hi</span>')
expect(out).not.toContain('onclick')
expect(out).toContain('hi')
})
const out = sanitizeInlineHtml('<span onclick="alert(1)">hi</span>');
expect(out).not.toContain('onclick');
expect(out).toContain('hi');
});
it('strips style attribute (CSS-injection surface)', () => {
const out = sanitizeInlineHtml('<span style="background:url(javascript:alert(1))">x</span>')
expect(out).not.toContain('style=')
expect(out).not.toContain('javascript:')
})
const out = sanitizeInlineHtml(
'<span style="background:url(javascript:alert(1))">x</span>',
);
expect(out).not.toContain('style=');
expect(out).not.toContain('javascript:');
});
it('strips iframe / object / embed / svg-with-script', () => {
expect(sanitizeInlineHtml('<iframe src="evil"></iframe>')).toBe('')
expect(sanitizeInlineHtml('<object data="evil"></object>')).toBe('')
expect(sanitizeInlineHtml('<embed src="evil" />')).toBe('')
expect(sanitizeInlineHtml('<svg><script>alert(1)</script></svg>')).not.toContain('script')
})
expect(sanitizeInlineHtml('<iframe src="evil"></iframe>')).toBe('');
expect(sanitizeInlineHtml('<object data="evil"></object>')).toBe('');
expect(sanitizeInlineHtml('<embed src="evil" />')).toBe('');
expect(
sanitizeInlineHtml('<svg><script>alert(1)</script></svg>'),
).not.toContain('script');
});
it('does not preserve href / target on the inline tag set', () => {
// <a> is not in the inline allow-list, so href can never appear here.
const out = sanitizeInlineHtml('<a href="javascript:alert(1)">x</a>')
expect(out).toBe('x')
})
const out = sanitizeInlineHtml('<a href="javascript:alert(1)">x</a>');
expect(out).toBe('x');
});
it('keeps user text content when the wrapping tag is stripped', () => {
expect(sanitizeInlineHtml('<custom-tag>hello</custom-tag>')).toBe('hello')
})
})
expect(sanitizeInlineHtml('<custom-tag>hello</custom-tag>')).toBe('hello');
});
});
describe('sanitizeRichTextHtml', () => {
it('preserves the full prose tag set', () => {
const html = '<p>hello <strong>world</strong></p><ul><li>one</li></ul>'
const out = sanitizeRichTextHtml(html)
expect(out).toContain('<p>')
expect(out).toContain('<strong>world</strong>')
expect(out).toContain('<li>one</li>')
})
const html = '<p>hello <strong>world</strong></p><ul><li>one</li></ul>';
const out = sanitizeRichTextHtml(html);
expect(out).toContain('<p>');
expect(out).toContain('<strong>world</strong>');
expect(out).toContain('<li>one</li>');
});
it('still strips <script> + on* + style', () => {
const out = sanitizeRichTextHtml('<p onclick="alert(1)" style="x">hi</p><script>x()</script>')
expect(out).not.toContain('onclick')
expect(out).not.toContain('style=')
expect(out).not.toContain('<script')
})
const out = sanitizeRichTextHtml(
'<p onclick="alert(1)" style="x">hi</p><script>x()</script>',
);
expect(out).not.toContain('onclick');
expect(out).not.toContain('style=');
expect(out).not.toContain('<script');
});
it('blocks javascript: hrefs', () => {
const out = sanitizeRichTextHtml('<a href="javascript:alert(1)">x</a>')
expect(out).not.toContain('javascript:')
})
const out = sanitizeRichTextHtml('<a href="javascript:alert(1)">x</a>');
expect(out).not.toContain('javascript:');
});
it('blocks data: hrefs that smuggle scripts', () => {
const out = sanitizeRichTextHtml('<a href="data:text/html,<script>alert(1)</script>">x</a>')
expect(out).not.toContain('data:text/html')
})
const out = sanitizeRichTextHtml(
'<a href="data:text/html,<script>alert(1)</script>">x</a>',
);
expect(out).not.toContain('data:text/html');
});
it('keeps http(s) hrefs intact', () => {
const out = sanitizeRichTextHtml('<a href="https://example.com">link</a>')
expect(out).toContain('href="https://example.com"')
})
const out = sanitizeRichTextHtml('<a href="https://example.com">link</a>');
expect(out).toContain('href="https://example.com"');
});
it('strips disallowed tags but keeps their content', () => {
expect(sanitizeRichTextHtml('<p>before<custom>middle</custom>after</p>')).toContain('middle')
})
expect(
sanitizeRichTextHtml('<p>before<custom>middle</custom>after</p>'),
).toContain('middle');
});
it('drops mathml + svg shorthand vectors', () => {
const mathPayload = '<math><mtext><script>alert(1)</script></mtext></math>'
const out = sanitizeRichTextHtml(mathPayload)
expect(out).not.toContain('<script')
expect(out).not.toContain('alert(1)')
})
})
const mathPayload = '<math><mtext><script>alert(1)</script></mtext></math>';
const out = sanitizeRichTextHtml(mathPayload);
expect(out).not.toContain('<script');
expect(out).not.toContain('alert(1)');
});
});
+38 -14
View File
@@ -1,4 +1,4 @@
import DOMPurify from 'isomorphic-dompurify'
import DOMPurify from 'isomorphic-dompurify';
/**
* HTML sanitisation for TREK.
@@ -18,18 +18,42 @@ import DOMPurify from 'isomorphic-dompurify'
// expected in the surfaces we render today, so we keep the allow-list minimal
// and rely on `sanitizeRichTextHtml` when a richer surface needs full prose.
const INLINE_TAGS = [
'b', 'strong', 'i', 'em', 'u', 's', 'del', 'ins',
'mark', 'code', 'sub', 'sup', 'br', 'span',
] as const
'b',
'strong',
'i',
'em',
'u',
's',
'del',
'ins',
'mark',
'code',
'sub',
'sup',
'br',
'span',
] as const;
const FULL_TAGS = [
...INLINE_TAGS,
'p', 'div', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'blockquote', 'pre', 'hr', 'a',
] as const
'p',
'div',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'pre',
'hr',
'a',
] as const;
const SAFE_ATTRIBUTES = ['href', 'rel', 'target'] as const
const SAFE_ATTRIBUTES = ['href', 'rel', 'target'] as const;
/**
* Escapes the five HTML metacharacters so a raw string can be safely
@@ -45,7 +69,7 @@ export function escapeHtml(value: string): string {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/'/g, '&#39;');
}
/**
@@ -58,13 +82,13 @@ export function escapeHtml(value: string): string {
* built-in URL allow-list.
*/
export function sanitizeInlineHtml(html: string): string {
if (!html) return ''
if (!html) return '';
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [...INLINE_TAGS],
ALLOWED_ATTR: [],
KEEP_CONTENT: true,
ALLOW_DATA_ATTR: false,
})
});
}
/**
@@ -73,10 +97,10 @@ export function sanitizeInlineHtml(html: string): string {
* the inline sanitiser plus block-level markup and anchors with safe attrs.
*/
export function sanitizeRichTextHtml(html: string): string {
if (!html) return ''
if (!html) return '';
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [...FULL_TAGS],
ALLOWED_ATTR: [...SAFE_ATTRIBUTES],
ALLOW_DATA_ATTR: false,
})
});
}
+23 -6
View File
@@ -1,18 +1,35 @@
import {
settingUpsertRequestSchema,
settingsBulkRequestSchema,
MASKED_SETTING_VALUE,
} from './settings.schema';
import { describe, it, expect } from 'vitest';
import { settingUpsertRequestSchema, settingsBulkRequestSchema, MASKED_SETTING_VALUE } from './settings.schema';
describe('settingUpsertRequestSchema', () => {
it('requires a key; value is any/optional', () => {
expect(settingUpsertRequestSchema.safeParse({ key: 'theme', value: 'dark' }).success).toBe(true);
expect(settingUpsertRequestSchema.safeParse({ key: 'theme' }).success).toBe(true);
expect(settingUpsertRequestSchema.safeParse({ value: 'dark' }).success).toBe(false);
expect(
settingUpsertRequestSchema.safeParse({ key: 'theme', value: 'dark' })
.success,
).toBe(true);
expect(settingUpsertRequestSchema.safeParse({ key: 'theme' }).success).toBe(
true,
);
expect(
settingUpsertRequestSchema.safeParse({ value: 'dark' }).success,
).toBe(false);
});
});
describe('settingsBulkRequestSchema', () => {
it('requires a settings record', () => {
expect(settingsBulkRequestSchema.safeParse({ settings: { a: 1, b: 'x' } }).success).toBe(true);
expect(settingsBulkRequestSchema.safeParse({ settings: {} }).success).toBe(true);
expect(
settingsBulkRequestSchema.safeParse({ settings: { a: 1, b: 'x' } })
.success,
).toBe(true);
expect(settingsBulkRequestSchema.safeParse({ settings: {} }).success).toBe(
true,
);
expect(settingsBulkRequestSchema.safeParse({}).success).toBe(false);
});
});
+9 -3
View File
@@ -1,13 +1,19 @@
import { describe, it, expect } from 'vitest';
import { shareLinkRequestSchema } from './share.schema';
import { describe, it, expect } from 'vitest';
describe('shareLinkRequestSchema', () => {
it('accepts any subset of section toggles (all optional) and an empty body', () => {
expect(shareLinkRequestSchema.safeParse({ share_map: true, share_budget: false }).success).toBe(true);
expect(
shareLinkRequestSchema.safeParse({ share_map: true, share_budget: false })
.success,
).toBe(true);
expect(shareLinkRequestSchema.safeParse({}).success).toBe(true);
});
it('rejects a non-boolean toggle', () => {
expect(shareLinkRequestSchema.safeParse({ share_map: 'yes' }).success).toBe(false);
expect(shareLinkRequestSchema.safeParse({ share_map: 'yes' }).success).toBe(
false,
);
});
});
@@ -1,42 +1,78 @@
import { describe, it, expect } from 'vitest';
import { systemNoticeDtoSchema } from './system-notice.schema';
import { describe, it, expect } from 'vitest';
describe('systemNoticeDtoSchema', () => {
it('accepts a minimal notice (required fields only)', () => {
const parsed = systemNoticeDtoSchema.parse({
id: 'welcome', display: 'modal', severity: 'info',
titleKey: 'notice.welcome.title', bodyKey: 'notice.welcome.body', dismissible: true,
id: 'welcome',
display: 'modal',
severity: 'info',
titleKey: 'notice.welcome.title',
bodyKey: 'notice.welcome.body',
dismissible: true,
});
expect(parsed.id).toBe('welcome');
});
it('accepts a rich notice with media, highlights and a nav CTA', () => {
expect(systemNoticeDtoSchema.safeParse({
id: 'release', display: 'banner', severity: 'warn',
titleKey: 't', bodyKey: 'b', dismissible: false,
bodyParams: { version: '3.1' },
icon: 'sparkles',
media: { src: '/img.png', altKey: 'alt', placement: 'hero' },
highlights: [{ labelKey: 'h1', iconName: 'check' }],
cta: { kind: 'nav', labelKey: 'open', href: '/whats-new' },
}).success).toBe(true);
expect(
systemNoticeDtoSchema.safeParse({
id: 'release',
display: 'banner',
severity: 'warn',
titleKey: 't',
bodyKey: 'b',
dismissible: false,
bodyParams: { version: '3.1' },
icon: 'sparkles',
media: { src: '/img.png', altKey: 'alt', placement: 'hero' },
highlights: [{ labelKey: 'h1', iconName: 'check' }],
cta: { kind: 'nav', labelKey: 'open', href: '/whats-new' },
}).success,
).toBe(true);
});
it('accepts an action CTA with the discriminated-union shape', () => {
expect(systemNoticeDtoSchema.safeParse({
id: 'x', display: 'toast', severity: 'critical',
titleKey: 't', bodyKey: 'b', dismissible: true,
cta: { kind: 'action', labelKey: 'do', actionId: 'reload', dismissOnAction: true },
}).success).toBe(true);
expect(
systemNoticeDtoSchema.safeParse({
id: 'x',
display: 'toast',
severity: 'critical',
titleKey: 't',
bodyKey: 'b',
dismissible: true,
cta: {
kind: 'action',
labelKey: 'do',
actionId: 'reload',
dismissOnAction: true,
},
}).success,
).toBe(true);
});
it('rejects an unknown display value and a malformed CTA', () => {
expect(systemNoticeDtoSchema.safeParse({
id: 'x', display: 'popup', severity: 'info', titleKey: 't', bodyKey: 'b', dismissible: true,
}).success).toBe(false);
expect(systemNoticeDtoSchema.safeParse({
id: 'x', display: 'modal', severity: 'info', titleKey: 't', bodyKey: 'b', dismissible: true,
cta: { kind: 'nav', labelKey: 'open' },
}).success).toBe(false);
expect(
systemNoticeDtoSchema.safeParse({
id: 'x',
display: 'popup',
severity: 'info',
titleKey: 't',
bodyKey: 'b',
dismissible: true,
}).success,
).toBe(false);
expect(
systemNoticeDtoSchema.safeParse({
id: 'x',
display: 'modal',
severity: 'info',
titleKey: 't',
bodyKey: 'b',
dismissible: true,
cta: { kind: 'nav', labelKey: 'open' },
}).success,
).toBe(false);
});
});
+17 -3
View File
@@ -1,15 +1,29 @@
import {
tagSchema,
createTagRequestSchema,
updateTagRequestSchema,
} from './tag.schema';
import { describe, it, expect } from 'vitest';
import { tagSchema, createTagRequestSchema, updateTagRequestSchema } from './tag.schema';
describe('tagSchema', () => {
it('accepts a full tag', () => {
expect(tagSchema.safeParse({ id: 1, user_id: 5, name: 'Beach', color: '#10b981' }).success).toBe(true);
expect(
tagSchema.safeParse({
id: 1,
user_id: 5,
name: 'Beach',
color: '#10b981',
}).success,
).toBe(true);
});
});
describe('createTagRequestSchema', () => {
it('requires a non-empty name; colour optional', () => {
expect(createTagRequestSchema.safeParse({ name: 'Beach' }).success).toBe(true);
expect(createTagRequestSchema.safeParse({ name: 'Beach' }).success).toBe(
true,
);
expect(createTagRequestSchema.safeParse({ name: '' }).success).toBe(false);
});
});
+29 -8
View File
@@ -1,30 +1,51 @@
import { describe, it, expect } from 'vitest';
import {
todoCreateItemRequestSchema,
todoUpdateItemRequestSchema,
todoReorderRequestSchema,
} from './todo.schema';
import { describe, it, expect } from 'vitest';
describe('todoCreateItemRequestSchema', () => {
it('requires a name; metadata optional with the service shapes', () => {
expect(todoCreateItemRequestSchema.safeParse({ name: 'Book hotel' }).success).toBe(true);
expect(todoCreateItemRequestSchema.safeParse({ name: 'X', due_date: '2026-07-01', priority: 2, assigned_user_id: 3 }).success).toBe(true);
expect(todoCreateItemRequestSchema.safeParse({ name: '' }).success).toBe(false);
expect(
todoCreateItemRequestSchema.safeParse({ name: 'Book hotel' }).success,
).toBe(true);
expect(
todoCreateItemRequestSchema.safeParse({
name: 'X',
due_date: '2026-07-01',
priority: 2,
assigned_user_id: 3,
}).success,
).toBe(true);
expect(todoCreateItemRequestSchema.safeParse({ name: '' }).success).toBe(
false,
);
// priority is numeric (matches the service), not a string
expect(todoCreateItemRequestSchema.safeParse({ name: 'X', priority: 'high' }).success).toBe(false);
expect(
todoCreateItemRequestSchema.safeParse({ name: 'X', priority: 'high' })
.success,
).toBe(false);
});
});
describe('todoUpdateItemRequestSchema', () => {
it('allows every field to be omitted and accepts checked', () => {
expect(todoUpdateItemRequestSchema.safeParse({}).success).toBe(true);
expect(todoUpdateItemRequestSchema.safeParse({ checked: true }).success).toBe(true);
expect(
todoUpdateItemRequestSchema.safeParse({ checked: true }).success,
).toBe(true);
});
});
describe('todoReorderRequestSchema', () => {
it('requires an array of numeric ids', () => {
expect(todoReorderRequestSchema.safeParse({ orderedIds: [1, 2, 3] }).success).toBe(true);
expect(todoReorderRequestSchema.safeParse({ orderedIds: ['a'] }).success).toBe(false);
expect(
todoReorderRequestSchema.safeParse({ orderedIds: [1, 2, 3] }).success,
).toBe(true);
expect(
todoReorderRequestSchema.safeParse({ orderedIds: ['a'] }).success,
).toBe(false);
});
});
+3 -1
View File
@@ -39,4 +39,6 @@ export type TodoReorderRequest = z.infer<typeof todoReorderRequestSchema>;
export const todoCategoryAssigneesRequestSchema = z.object({
user_ids: z.array(z.number()),
});
export type TodoCategoryAssigneesRequest = z.infer<typeof todoCategoryAssigneesRequestSchema>;
export type TodoCategoryAssigneesRequest = z.infer<
typeof todoCategoryAssigneesRequestSchema
>;
+19 -5
View File
@@ -1,14 +1,23 @@
import { describe, it, expect } from 'vitest';
import {
tripCreateRequestSchema,
tripUpdateRequestSchema,
tripAddMemberRequestSchema,
} from './trip.schema';
import { describe, it, expect } from 'vitest';
describe('tripCreateRequestSchema', () => {
it('requires a title; dates/currency/reminder optional', () => {
expect(tripCreateRequestSchema.safeParse({ title: 'Japan' }).success).toBe(true);
expect(tripCreateRequestSchema.safeParse({ title: 'Japan', start_date: '2026-07-01', day_count: 7 }).success).toBe(true);
expect(tripCreateRequestSchema.safeParse({ title: 'Japan' }).success).toBe(
true,
);
expect(
tripCreateRequestSchema.safeParse({
title: 'Japan',
start_date: '2026-07-01',
day_count: 7,
}).success,
).toBe(true);
expect(tripCreateRequestSchema.safeParse({}).success).toBe(false);
});
});
@@ -16,13 +25,18 @@ describe('tripCreateRequestSchema', () => {
describe('tripUpdateRequestSchema', () => {
it('is fully partial and accepts is_archived + cover_image', () => {
expect(tripUpdateRequestSchema.safeParse({}).success).toBe(true);
expect(tripUpdateRequestSchema.safeParse({ is_archived: 1, cover_image: null }).success).toBe(true);
expect(
tripUpdateRequestSchema.safeParse({ is_archived: 1, cover_image: null })
.success,
).toBe(true);
});
});
describe('tripAddMemberRequestSchema', () => {
it('requires an identifier', () => {
expect(tripAddMemberRequestSchema.safeParse({ identifier: 'bob@x.y' }).success).toBe(true);
expect(
tripAddMemberRequestSchema.safeParse({ identifier: 'bob@x.y' }).success,
).toBe(true);
expect(tripAddMemberRequestSchema.safeParse({}).success).toBe(false);
});
});
+36 -10
View File
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import {
vacayAddHolidayCalendarRequestSchema,
vacayInviteRequestSchema,
@@ -6,34 +5,61 @@ import {
vacayAddYearRequestSchema,
} from './vacay.schema';
import { describe, it, expect } from 'vitest';
describe('vacayAddHolidayCalendarRequestSchema', () => {
it('requires a region; label/color/sort_order optional', () => {
expect(vacayAddHolidayCalendarRequestSchema.safeParse({ region: 'DE-BY' }).success).toBe(true);
expect(vacayAddHolidayCalendarRequestSchema.safeParse({ region: 'DE-BY', label: null }).success).toBe(true);
expect(vacayAddHolidayCalendarRequestSchema.safeParse({}).success).toBe(false);
expect(
vacayAddHolidayCalendarRequestSchema.safeParse({ region: 'DE-BY' })
.success,
).toBe(true);
expect(
vacayAddHolidayCalendarRequestSchema.safeParse({
region: 'DE-BY',
label: null,
}).success,
).toBe(true);
expect(vacayAddHolidayCalendarRequestSchema.safeParse({}).success).toBe(
false,
);
});
});
describe('vacayInviteRequestSchema', () => {
it('accepts a numeric or string user_id', () => {
expect(vacayInviteRequestSchema.safeParse({ user_id: 2 }).success).toBe(true);
expect(vacayInviteRequestSchema.safeParse({ user_id: '2' }).success).toBe(true);
expect(vacayInviteRequestSchema.safeParse({ user_id: 2 }).success).toBe(
true,
);
expect(vacayInviteRequestSchema.safeParse({ user_id: '2' }).success).toBe(
true,
);
expect(vacayInviteRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('vacayToggleEntryRequestSchema', () => {
it('requires a date; target_user_id optional', () => {
expect(vacayToggleEntryRequestSchema.safeParse({ date: '2026-07-01' }).success).toBe(true);
expect(vacayToggleEntryRequestSchema.safeParse({ date: '2026-07-01', target_user_id: 3 }).success).toBe(true);
expect(
vacayToggleEntryRequestSchema.safeParse({ date: '2026-07-01' }).success,
).toBe(true);
expect(
vacayToggleEntryRequestSchema.safeParse({
date: '2026-07-01',
target_user_id: 3,
}).success,
).toBe(true);
expect(vacayToggleEntryRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('vacayAddYearRequestSchema', () => {
it('accepts a numeric or string year', () => {
expect(vacayAddYearRequestSchema.safeParse({ year: 2027 }).success).toBe(true);
expect(vacayAddYearRequestSchema.safeParse({ year: '2027' }).success).toBe(true);
expect(vacayAddYearRequestSchema.safeParse({ year: 2027 }).success).toBe(
true,
);
expect(vacayAddYearRequestSchema.safeParse({ year: '2027' }).success).toBe(
true,
);
expect(vacayAddYearRequestSchema.safeParse({}).success).toBe(false);
});
});
+15 -5
View File
@@ -22,7 +22,9 @@ export const vacayAddHolidayCalendarRequestSchema = z.object({
color: z.string().optional(),
sort_order: z.number().optional(),
});
export type VacayAddHolidayCalendarRequest = z.infer<typeof vacayAddHolidayCalendarRequestSchema>;
export type VacayAddHolidayCalendarRequest = z.infer<
typeof vacayAddHolidayCalendarRequestSchema
>;
export const vacaySetColorRequestSchema = z.object({
color: z.string().optional(),
@@ -38,7 +40,9 @@ export type VacayInviteRequest = z.infer<typeof vacayInviteRequestSchema>;
export const vacayInviteActionRequestSchema = z.object({
plan_id: z.number().optional(),
});
export type VacayInviteActionRequest = z.infer<typeof vacayInviteActionRequestSchema>;
export type VacayInviteActionRequest = z.infer<
typeof vacayInviteActionRequestSchema
>;
export const vacayAddYearRequestSchema = z.object({
year: z.union([z.number(), z.string()]),
@@ -49,19 +53,25 @@ export const vacayToggleEntryRequestSchema = z.object({
date: z.string().min(1),
target_user_id: z.union([z.number(), z.string()]).optional(),
});
export type VacayToggleEntryRequest = z.infer<typeof vacayToggleEntryRequestSchema>;
export type VacayToggleEntryRequest = z.infer<
typeof vacayToggleEntryRequestSchema
>;
export const vacayCompanyHolidayRequestSchema = z.object({
date: z.string(),
note: z.string().optional(),
});
export type VacayCompanyHolidayRequest = z.infer<typeof vacayCompanyHolidayRequestSchema>;
export type VacayCompanyHolidayRequest = z.infer<
typeof vacayCompanyHolidayRequestSchema
>;
export const vacayUpdateStatsRequestSchema = z.object({
vacation_days: z.number().optional(),
target_user_id: z.union([z.number(), z.string()]).optional(),
});
export type VacayUpdateStatsRequest = z.infer<typeof vacayUpdateStatsRequestSchema>;
export type VacayUpdateStatsRequest = z.infer<
typeof vacayUpdateStatsRequestSchema
>;
/** Plan / entries / stats payloads are wide and DB-derived; kept open. */
export const vacayPlanDataSchema = open;