@schafevormfenster/rest-commons 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +1190 -0
- package/README.md +275 -0
- package/bin/setup.js +10 -0
- package/dist/api-schemas/error.schema.d.ts +20 -0
- package/dist/api-schemas/error.schema.d.ts.map +1 -0
- package/dist/api-schemas/error.schema.js +17 -0
- package/dist/api-schemas/health.schema.d.ts +497 -0
- package/dist/api-schemas/health.schema.d.ts.map +1 -0
- package/dist/api-schemas/health.schema.js +33 -0
- package/dist/api-schemas/okay.schema.d.ts +13 -0
- package/dist/api-schemas/okay.schema.d.ts.map +1 -0
- package/dist/api-schemas/okay.schema.js +5 -0
- package/dist/api-schemas/paginated-results.schema.d.ts +59 -0
- package/dist/api-schemas/paginated-results.schema.d.ts.map +1 -0
- package/dist/api-schemas/paginated-results.schema.js +10 -0
- package/dist/api-schemas/partial-results.schema.d.ts +30 -0
- package/dist/api-schemas/partial-results.schema.d.ts.map +1 -0
- package/dist/api-schemas/partial-results.schema.js +10 -0
- package/dist/api-schemas/result.schema.d.ts +17 -0
- package/dist/api-schemas/result.schema.d.ts.map +1 -0
- package/dist/api-schemas/result.schema.js +5 -0
- package/dist/api-schemas/results.schema.d.ts +21 -0
- package/dist/api-schemas/results.schema.d.ts.map +1 -0
- package/dist/api-schemas/results.schema.js +5 -0
- package/dist/helpers/correlation/get-correlation-id.d.ts +7 -0
- package/dist/helpers/correlation/get-correlation-id.d.ts.map +1 -0
- package/dist/helpers/correlation/get-correlation-id.js +16 -0
- package/dist/helpers/correlation/get-header.d.ts +7 -0
- package/dist/helpers/correlation/get-header.d.ts.map +1 -0
- package/dist/helpers/correlation/get-header.js +11 -0
- package/dist/helpers/detect-mime-type.d.ts +11 -0
- package/dist/helpers/detect-mime-type.d.ts.map +1 -0
- package/dist/helpers/detect-mime-type.js +40 -0
- package/dist/helpers/detect-suspicious-patterns.d.ts +8 -0
- package/dist/helpers/detect-suspicious-patterns.d.ts.map +1 -0
- package/dist/helpers/detect-suspicious-patterns.js +55 -0
- package/dist/helpers/eventify-constants.types.d.ts +32 -0
- package/dist/helpers/eventify-constants.types.d.ts.map +1 -0
- package/dist/helpers/eventify-constants.types.js +40 -0
- package/dist/helpers/hash-binary.d.ts +21 -0
- package/dist/helpers/hash-binary.d.ts.map +1 -0
- package/dist/helpers/hash-binary.js +28 -0
- package/dist/helpers/mime-types/detect-image-mime-type.d.ts +5 -0
- package/dist/helpers/mime-types/detect-image-mime-type.d.ts.map +1 -0
- package/dist/helpers/mime-types/detect-image-mime-type.js +41 -0
- package/dist/helpers/mime-types/detect-ole-mime-type.d.ts +6 -0
- package/dist/helpers/mime-types/detect-ole-mime-type.d.ts.map +1 -0
- package/dist/helpers/mime-types/detect-ole-mime-type.js +34 -0
- package/dist/helpers/mime-types/detect-pdf-mime-type.d.ts +5 -0
- package/dist/helpers/mime-types/detect-pdf-mime-type.d.ts.map +1 -0
- package/dist/helpers/mime-types/detect-pdf-mime-type.js +13 -0
- package/dist/helpers/mime-types/detect-zip-mime-type.d.ts +6 -0
- package/dist/helpers/mime-types/detect-zip-mime-type.d.ts.map +1 -0
- package/dist/helpers/mime-types/detect-zip-mime-type.js +23 -0
- package/dist/helpers/parameter-validation.d.ts +6 -0
- package/dist/helpers/parameter-validation.d.ts.map +1 -0
- package/dist/helpers/parameter-validation.js +19 -0
- package/dist/helpers/parameter-validation.types.d.ts +16 -0
- package/dist/helpers/parameter-validation.types.d.ts.map +1 -0
- package/dist/helpers/parameter-validation.types.js +38 -0
- package/dist/helpers/response-headers/build-api-unauthorized-headers.d.ts +6 -0
- package/dist/helpers/response-headers/build-api-unauthorized-headers.d.ts.map +1 -0
- package/dist/helpers/response-headers/build-api-unauthorized-headers.js +23 -0
- package/dist/helpers/response-headers/environment.types.d.ts +2 -0
- package/dist/helpers/response-headers/environment.types.d.ts.map +1 -0
- package/dist/helpers/response-headers/environment.types.js +1 -0
- package/dist/helpers/response-headers/resolve-environment.d.ts +8 -0
- package/dist/helpers/response-headers/resolve-environment.d.ts.map +1 -0
- package/dist/helpers/response-headers/resolve-environment.js +18 -0
- package/dist/helpers/slugify.d.ts +15 -0
- package/dist/helpers/slugify.d.ts.map +1 -0
- package/dist/helpers/slugify.js +32 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/normalization/normalize-list.d.ts +11 -0
- package/dist/normalization/normalize-list.d.ts.map +1 -0
- package/dist/normalization/normalize-list.js +19 -0
- package/dist/normalization/normalize-location.d.ts +16 -0
- package/dist/normalization/normalize-location.d.ts.map +1 -0
- package/dist/normalization/normalize-location.js +26 -0
- package/dist/primitives/coordinate-precision.d.ts +10 -0
- package/dist/primitives/coordinate-precision.d.ts.map +1 -0
- package/dist/primitives/coordinate-precision.js +27 -0
- package/dist/primitives/geo-point.schema.d.ts +8 -0
- package/dist/primitives/geo-point.schema.d.ts.map +1 -0
- package/dist/primitives/geo-point.schema.js +10 -0
- package/dist/primitives/geoname-id.schema.d.ts +8 -0
- package/dist/primitives/geoname-id.schema.d.ts.map +1 -0
- package/dist/primitives/geoname-id.schema.js +9 -0
- package/dist/primitives/international-zip.schema.d.ts +76 -0
- package/dist/primitives/international-zip.schema.d.ts.map +1 -0
- package/dist/primitives/international-zip.schema.js +81 -0
- package/dist/primitives/latitude.schema.d.ts +9 -0
- package/dist/primitives/latitude.schema.d.ts.map +1 -0
- package/dist/primitives/latitude.schema.js +13 -0
- package/dist/primitives/location.schema.d.ts +8 -0
- package/dist/primitives/location.schema.d.ts.map +1 -0
- package/dist/primitives/location.schema.js +15 -0
- package/dist/primitives/longitude.schema.d.ts +9 -0
- package/dist/primitives/longitude.schema.d.ts.map +1 -0
- package/dist/primitives/longitude.schema.js +13 -0
- package/dist/primitives/numeric-id.schema.d.ts +8 -0
- package/dist/primitives/numeric-id.schema.d.ts.map +1 -0
- package/dist/primitives/numeric-id.schema.js +10 -0
- package/dist/primitives/slug.schema.d.ts +17 -0
- package/dist/primitives/slug.schema.d.ts.map +1 -0
- package/dist/primitives/slug.schema.js +30 -0
- package/dist/primitives/uuid.schema.d.ts +8 -0
- package/dist/primitives/uuid.schema.d.ts.map +1 -0
- package/dist/primitives/uuid.schema.js +9 -0
- package/dist/primitives/wikidata-id.schema.d.ts +9 -0
- package/dist/primitives/wikidata-id.schema.d.ts.map +1 -0
- package/dist/primitives/wikidata-id.schema.js +10 -0
- package/dist/time/boundary-enforcement.d.ts +11 -0
- package/dist/time/boundary-enforcement.d.ts.map +1 -0
- package/dist/time/boundary-enforcement.js +43 -0
- package/dist/time/bounded-time.schema.d.ts +31 -0
- package/dist/time/bounded-time.schema.d.ts.map +1 -0
- package/dist/time/bounded-time.schema.js +77 -0
- package/dist/time/flexible-time-parser.d.ts +12 -0
- package/dist/time/flexible-time-parser.d.ts.map +1 -0
- package/dist/time/flexible-time-parser.js +94 -0
- package/dist/time/flexible-time.schema.d.ts +31 -0
- package/dist/time/flexible-time.schema.d.ts.map +1 -0
- package/dist/time/flexible-time.schema.js +31 -0
- package/dist/time/get-week-end.d.ts +10 -0
- package/dist/time/get-week-end.d.ts.map +1 -0
- package/dist/time/get-week-end.js +25 -0
- package/dist/time/get-week-start.d.ts +10 -0
- package/dist/time/get-week-start.d.ts.map +1 -0
- package/dist/time/get-week-start.js +25 -0
- package/dist/time/is-relative-time.d.ts +8 -0
- package/dist/time/is-relative-time.d.ts.map +1 -0
- package/dist/time/is-relative-time.js +9 -0
- package/dist/time/iso8601.schema.d.ts +14 -0
- package/dist/time/iso8601.schema.d.ts.map +1 -0
- package/dist/time/iso8601.schema.js +17 -0
- package/dist/time/iso8601.types.d.ts +6 -0
- package/dist/time/iso8601.types.d.ts.map +1 -0
- package/dist/time/iso8601.types.js +11 -0
- package/dist/time/parse-relative-time.d.ts +9 -0
- package/dist/time/parse-relative-time.d.ts.map +1 -0
- package/dist/time/parse-relative-time.js +36 -0
- package/dist/time/relative-time.schema.d.ts +23 -0
- package/dist/time/relative-time.schema.d.ts.map +1 -0
- package/dist/time/relative-time.schema.js +25 -0
- package/dist/time/since-parameter.schema.d.ts +8 -0
- package/dist/time/since-parameter.schema.d.ts.map +1 -0
- package/dist/time/since-parameter.schema.js +56 -0
- package/dist/time/time-helpers.d.ts +19 -0
- package/dist/time/time-helpers.d.ts.map +1 -0
- package/dist/time/time-helpers.js +56 -0
- package/dist/time/time-schemas.d.ts +20 -0
- package/dist/time/time-schemas.d.ts.map +1 -0
- package/dist/time/time-schemas.js +25 -0
- package/dist/time/timezone.types.d.ts +17 -0
- package/dist/time/timezone.types.d.ts.map +1 -0
- package/dist/time/timezone.types.js +15 -0
- package/dist/validation/zod-error-handler.d.ts +3 -0
- package/dist/validation/zod-error-handler.d.ts.map +1 -0
- package/dist/validation/zod-error-handler.js +189 -0
- package/dist/validation/zod-utils.d.ts +9 -0
- package/dist/validation/zod-utils.d.ts.map +1 -0
- package/dist/validation/zod-utils.js +23 -0
- package/eslint.config.mjs +16 -0
- package/package.json +44 -0
- package/src/api-schemas/error.schema.test.ts +27 -0
- package/src/api-schemas/error.schema.ts +23 -0
- package/src/api-schemas/health.schema.test.ts +104 -0
- package/src/api-schemas/health.schema.ts +63 -0
- package/src/api-schemas/okay.schema.test.ts +15 -0
- package/src/api-schemas/okay.schema.ts +8 -0
- package/src/api-schemas/paginated-results.schema.ts +17 -0
- package/src/api-schemas/partial-results.schema.ts +13 -0
- package/src/api-schemas/result.schema.test.ts +19 -0
- package/src/api-schemas/result.schema.ts +9 -0
- package/src/api-schemas/results.schema.test.ts +15 -0
- package/src/api-schemas/results.schema.ts +9 -0
- package/src/helpers/correlation/get-correlation-id.test.ts +126 -0
- package/src/helpers/correlation/get-correlation-id.ts +22 -0
- package/src/helpers/correlation/get-header.test.ts +179 -0
- package/src/helpers/correlation/get-header.ts +21 -0
- package/src/helpers/detect-mime-type.test.ts +100 -0
- package/src/helpers/detect-mime-type.ts +46 -0
- package/src/helpers/detect-suspicious-patterns.test.ts +45 -0
- package/src/helpers/detect-suspicious-patterns.ts +57 -0
- package/src/helpers/eventify-constants.test.ts +52 -0
- package/src/helpers/eventify-constants.types.test.ts +52 -0
- package/src/helpers/eventify-constants.types.ts +51 -0
- package/src/helpers/hash-binary.test.ts +60 -0
- package/src/helpers/hash-binary.ts +30 -0
- package/src/helpers/mime-types/detect-image-mime-type.test.ts +73 -0
- package/src/helpers/mime-types/detect-image-mime-type.ts +50 -0
- package/src/helpers/mime-types/detect-ole-mime-type.test.ts +86 -0
- package/src/helpers/mime-types/detect-ole-mime-type.ts +44 -0
- package/src/helpers/mime-types/detect-pdf-mime-type.test.ts +39 -0
- package/src/helpers/mime-types/detect-pdf-mime-type.ts +15 -0
- package/src/helpers/mime-types/detect-zip-mime-type.test.ts +88 -0
- package/src/helpers/mime-types/detect-zip-mime-type.ts +28 -0
- package/src/helpers/parameter-validation.test.ts +35 -0
- package/src/helpers/parameter-validation.ts +32 -0
- package/src/helpers/process-eventify-request.ts +146 -0
- package/src/helpers/response-headers/build-api-unauthorized-headers.ts +30 -0
- package/src/helpers/response-headers/environment.types.ts +1 -0
- package/src/helpers/response-headers/resolve-environment.ts +17 -0
- package/src/helpers/slugify.test.ts +77 -0
- package/src/helpers/slugify.ts +34 -0
- package/src/index.ts +46 -0
- package/src/normalization/normalize-list.test.ts +43 -0
- package/src/normalization/normalize-list.ts +21 -0
- package/src/normalization/normalize-location.test.ts +91 -0
- package/src/normalization/normalize-location.ts +29 -0
- package/src/primitives/coordinate-precision.test.ts +46 -0
- package/src/primitives/coordinate-precision.ts +30 -0
- package/src/primitives/geo-point.schema.test.ts +70 -0
- package/src/primitives/geo-point.schema.ts +14 -0
- package/src/primitives/geoname-id.schema.test.ts +60 -0
- package/src/primitives/geoname-id.schema.ts +12 -0
- package/src/primitives/international-zip.schema.test.ts +212 -0
- package/src/primitives/international-zip.schema.ts +103 -0
- package/src/primitives/latitude.schema.test.ts +77 -0
- package/src/primitives/latitude.schema.ts +20 -0
- package/src/primitives/location.schema.test.ts +21 -0
- package/src/primitives/location.schema.ts +22 -0
- package/src/primitives/longitude.schema.test.ts +77 -0
- package/src/primitives/longitude.schema.ts +20 -0
- package/src/primitives/numeric-id.schema.test.ts +32 -0
- package/src/primitives/numeric-id.schema.ts +13 -0
- package/src/primitives/slug.schema.test.ts +101 -0
- package/src/primitives/slug.schema.ts +41 -0
- package/src/primitives/uuid.schema.test.ts +45 -0
- package/src/primitives/uuid.schema.ts +12 -0
- package/src/primitives/wikidata-id.schema.test.ts +51 -0
- package/src/primitives/wikidata-id.schema.ts +16 -0
- package/src/time/README.md +220 -0
- package/src/time/boundary-enforcement.test.ts +130 -0
- package/src/time/boundary-enforcement.ts +59 -0
- package/src/time/bounded-time.schema.test.ts +294 -0
- package/src/time/bounded-time.schema.ts +111 -0
- package/src/time/flexible-time-parser.test.ts +586 -0
- package/src/time/flexible-time-parser.ts +122 -0
- package/src/time/flexible-time.schema.test.ts +243 -0
- package/src/time/flexible-time.schema.ts +43 -0
- package/src/time/is-relative-time.test.ts +23 -0
- package/src/time/is-relative-time.ts +9 -0
- package/src/time/iso8601.schema.ts +29 -0
- package/src/time/iso8601.types.test.ts +112 -0
- package/src/time/iso8601.types.ts +21 -0
- package/src/time/parse-relative-time.test.ts +49 -0
- package/src/time/parse-relative-time.ts +50 -0
- package/src/time/relative-time.schema.test.ts +23 -0
- package/src/time/relative-time.schema.ts +38 -0
- package/src/time/since-parameter.schema.test.ts +59 -0
- package/src/time/since-parameter.schema.ts +69 -0
- package/src/time/time-helpers.test.ts +263 -0
- package/src/time/time-helpers.ts +78 -0
- package/src/time/time-schemas.test.ts +181 -0
- package/src/time/time-schemas.ts +42 -0
- package/src/time/time.schema.test.ts +237 -0
- package/src/time/timezone-independence.test.ts +188 -0
- package/src/time/timezone.types.test.ts +55 -0
- package/src/time/timezone.types.ts +22 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { SlugSchema, SlugSchemaWithTransform } from "./slug.schema";
|
|
4
|
+
|
|
5
|
+
describe("SlugSchema", () => {
|
|
6
|
+
it("should accept valid slug strings", () => {
|
|
7
|
+
expect(SlugSchema.parse("my-community")).toBe("my-community");
|
|
8
|
+
expect(SlugSchema.parse("test-123")).toBe("test-123");
|
|
9
|
+
expect(SlugSchema.parse("abc-def-ghi")).toBe("abc-def-ghi");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should accept single hyphen-separated words", () => {
|
|
13
|
+
expect(SlugSchema.parse("ab")).toBe("ab");
|
|
14
|
+
expect(SlugSchema.parse("test-slug")).toBe("test-slug");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should reject strings shorter than 2 characters", () => {
|
|
18
|
+
expect(() => SlugSchema.parse("a")).toThrow("Slug must be at least 2 characters long");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should reject strings longer than 150 characters", () => {
|
|
22
|
+
expect(() => SlugSchema.parse("a".repeat(151))).toThrow("Slug must not exceed 150 characters");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should reject uppercase letters", () => {
|
|
26
|
+
expect(() => SlugSchema.parse("My-Community")).toThrow(
|
|
27
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens"
|
|
28
|
+
);
|
|
29
|
+
expect(() => SlugSchema.parse("TEST")).toThrow(
|
|
30
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens"
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should reject spaces", () => {
|
|
35
|
+
expect(() => SlugSchema.parse("my community")).toThrow(
|
|
36
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens"
|
|
37
|
+
);
|
|
38
|
+
expect(() => SlugSchema.parse("test slug")).toThrow(
|
|
39
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens"
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should reject special characters", () => {
|
|
44
|
+
expect(() => SlugSchema.parse("my_community")).toThrow(
|
|
45
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens"
|
|
46
|
+
);
|
|
47
|
+
expect(() => SlugSchema.parse("test.slug")).toThrow(
|
|
48
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens"
|
|
49
|
+
);
|
|
50
|
+
expect(() => SlugSchema.parse("test@slug")).toThrow(
|
|
51
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens"
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should reject slugs starting or ending with hyphen", () => {
|
|
56
|
+
expect(() => SlugSchema.parse("-my-community")).toThrow(
|
|
57
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens"
|
|
58
|
+
);
|
|
59
|
+
expect(() => SlugSchema.parse("my-community-")).toThrow(
|
|
60
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens"
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should reject consecutive hyphens", () => {
|
|
65
|
+
expect(() => SlugSchema.parse("my--community")).toThrow(
|
|
66
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens"
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should reject strings with suspicious patterns", () => {
|
|
71
|
+
expect(() => SlugSchema.parse("community<script>")).toThrow();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("SlugSchemaWithTransform", () => {
|
|
76
|
+
it("should transform and validate strings into slugs", () => {
|
|
77
|
+
expect(SlugSchemaWithTransform.parse("Hello World")).toBe("hello-world");
|
|
78
|
+
expect(SlugSchemaWithTransform.parse("My Community")).toBe("my-community");
|
|
79
|
+
expect(SlugSchemaWithTransform.parse("Berlin, Germany")).toBe("berlin-germany");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should handle German umlauts", () => {
|
|
83
|
+
expect(SlugSchemaWithTransform.parse("Café in München")).toBe("cafe-in-muenchen");
|
|
84
|
+
expect(SlugSchemaWithTransform.parse("Äpfel und Öl")).toBe("aepfel-und-oel");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should handle already valid slugs", () => {
|
|
88
|
+
expect(SlugSchemaWithTransform.parse("my-community")).toBe("my-community");
|
|
89
|
+
expect(SlugSchemaWithTransform.parse("test-slug")).toBe("test-slug");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should reject slugs that become too short after transformation", () => {
|
|
93
|
+
expect(() => SlugSchemaWithTransform.parse("!")).toThrow();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should reject slugs that become too long after transformation", () => {
|
|
97
|
+
expect(() => SlugSchemaWithTransform.parse("a".repeat(151))).toThrow(
|
|
98
|
+
"Slug must not exceed 150 characters"
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { validateNoSuspiciousPatterns } from "../helpers/parameter-validation";
|
|
4
|
+
import { slugify } from "../helpers/slugify";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Zod schema for validating slug parameter in community slug endpoint.
|
|
8
|
+
* Checks for length between 2-150 characters and suspicious patterns.
|
|
9
|
+
* Enforces lowercase, no spaces, no special characters (only alphanumeric and hyphens).
|
|
10
|
+
*/
|
|
11
|
+
export const SlugSchema = z
|
|
12
|
+
.string()
|
|
13
|
+
.min(2, "Slug must be at least 2 characters long")
|
|
14
|
+
.max(150, "Slug must not exceed 150 characters")
|
|
15
|
+
.regex(
|
|
16
|
+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
17
|
+
"Slug must be lowercase, contain only alphanumeric characters and hyphens, and not start or end with a hyphen"
|
|
18
|
+
)
|
|
19
|
+
.refine(
|
|
20
|
+
(value) => {
|
|
21
|
+
validateNoSuspiciousPatterns(value, "slug");
|
|
22
|
+
return true;
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
message: "Slug parameter contains suspicious patterns that are not allowed",
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Zod schema that validates and transforms strings into slugs.
|
|
31
|
+
* Automatically converts input to URL-safe format.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* SlugSchemaWithTransform.parse("Hello World!") // "hello-world"
|
|
35
|
+
*/
|
|
36
|
+
export const SlugSchemaWithTransform = z
|
|
37
|
+
.string()
|
|
38
|
+
.transform(slugify)
|
|
39
|
+
.pipe(SlugSchema);
|
|
40
|
+
|
|
41
|
+
export type Slug = z.infer<typeof SlugSchema>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { UuidSchema } from "./uuid.schema";
|
|
4
|
+
|
|
5
|
+
describe("UuidSchema", () => {
|
|
6
|
+
it("should accept valid UUID v4 strings", () => {
|
|
7
|
+
expect(UuidSchema.parse("550e8400-e29b-41d4-a716-446655440000")).toBe(
|
|
8
|
+
"550e8400-e29b-41d4-a716-446655440000"
|
|
9
|
+
);
|
|
10
|
+
expect(UuidSchema.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8")).toBe(
|
|
11
|
+
"6ba7b810-9dad-11d1-80b4-00c04fd430c8"
|
|
12
|
+
);
|
|
13
|
+
expect(UuidSchema.parse("00000000-0000-0000-0000-000000000000")).toBe(
|
|
14
|
+
"00000000-0000-0000-0000-000000000000"
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should accept UUIDs in uppercase", () => {
|
|
19
|
+
expect(UuidSchema.parse("550E8400-E29B-41D4-A716-446655440000")).toBe(
|
|
20
|
+
"550E8400-E29B-41D4-A716-446655440000"
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should reject invalid UUID formats", () => {
|
|
25
|
+
expect(() => UuidSchema.parse("not-a-uuid")).toThrow("Must be a valid UUID v4");
|
|
26
|
+
expect(() => UuidSchema.parse("550e8400-e29b-41d4-a716")).toThrow("Must be a valid UUID v4");
|
|
27
|
+
expect(() => UuidSchema.parse("550e8400e29b41d4a716446655440000")).toThrow("Must be a valid UUID v4");
|
|
28
|
+
expect(() => UuidSchema.parse("")).toThrow("Must be a valid UUID v4");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should reject UUIDs with invalid characters", () => {
|
|
32
|
+
expect(() => UuidSchema.parse("550e8400-e29b-41d4-a716-44665544000g")).toThrow(
|
|
33
|
+
"Must be a valid UUID v4"
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should reject non-string values", () => {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
expect(() => UuidSchema.parse(123 as any)).toThrow();
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, unicorn/no-null
|
|
41
|
+
expect(() => UuidSchema.parse(null as any)).toThrow();
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
+
expect(() => UuidSchema.parse(undefined as any)).toThrow();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Zod schema for validating UUID v4 format.
|
|
5
|
+
* Follows RFC 4122 specification.
|
|
6
|
+
*/
|
|
7
|
+
export const UuidSchema = z
|
|
8
|
+
.string()
|
|
9
|
+
.uuid("Must be a valid UUID v4")
|
|
10
|
+
.describe("UUID v4 format (e.g., '550e8400-e29b-41d4-a716-446655440000')");
|
|
11
|
+
|
|
12
|
+
export type Uuid = z.infer<typeof UuidSchema>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { WikidataIdSchema } from "./wikidata-id.schema";
|
|
4
|
+
|
|
5
|
+
describe("WikidataIdSchema", () => {
|
|
6
|
+
it("should accept valid Wikidata QIDs", () => {
|
|
7
|
+
expect(WikidataIdSchema.parse("Q1")).toBe("Q1"); // Universe
|
|
8
|
+
expect(WikidataIdSchema.parse("Q64")).toBe("Q64"); // Berlin
|
|
9
|
+
expect(WikidataIdSchema.parse("Q60")).toBe("Q60"); // New York
|
|
10
|
+
expect(WikidataIdSchema.parse("Q123456789")).toBe("Q123456789");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("should reject QIDs starting with Q0", () => {
|
|
14
|
+
expect(() => WikidataIdSchema.parse("Q0")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
15
|
+
expect(() => WikidataIdSchema.parse("Q01")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
16
|
+
expect(() => WikidataIdSchema.parse("Q012")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should reject QIDs without the Q prefix", () => {
|
|
20
|
+
expect(() => WikidataIdSchema.parse("64")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
21
|
+
expect(() => WikidataIdSchema.parse("1")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should reject lowercase q", () => {
|
|
25
|
+
expect(() => WikidataIdSchema.parse("q64")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
26
|
+
expect(() => WikidataIdSchema.parse("q1")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should reject QIDs with non-numeric characters", () => {
|
|
30
|
+
expect(() => WikidataIdSchema.parse("Q64a")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
31
|
+
expect(() => WikidataIdSchema.parse("Qabc")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
32
|
+
expect(() => WikidataIdSchema.parse("Q6-4")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should reject empty Q", () => {
|
|
36
|
+
expect(() => WikidataIdSchema.parse("Q")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should reject empty strings", () => {
|
|
40
|
+
expect(() => WikidataIdSchema.parse("")).toThrow("Must be a valid Wikidata QID (e.g., 'Q64')");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should reject non-string values", () => {
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
expect(() => WikidataIdSchema.parse(64 as any)).toThrow();
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, unicorn/no-null
|
|
47
|
+
expect(() => WikidataIdSchema.parse(null as any)).toThrow();
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
+
expect(() => WikidataIdSchema.parse(undefined as any)).toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Zod schema for validating Wikidata QID format.
|
|
5
|
+
* QIDs are unique identifiers for entities in Wikidata.
|
|
6
|
+
* Format: Q followed by one or more digits (e.g., "Q64" for Berlin)
|
|
7
|
+
*/
|
|
8
|
+
export const WikidataIdSchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.regex(
|
|
11
|
+
/^Q[1-9]\d*$/,
|
|
12
|
+
"Must be a valid Wikidata QID (e.g., 'Q64')"
|
|
13
|
+
)
|
|
14
|
+
.describe("Wikidata entity identifier (QID)");
|
|
15
|
+
|
|
16
|
+
export type WikidataId = z.infer<typeof WikidataIdSchema>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Time Schema Architecture
|
|
2
|
+
|
|
3
|
+
This document describes the unified time schema architecture for flexible and type-safe time handling.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The time schema module provides a comprehensive, composable set of Zod schemas for handling time inputs in various formats. It supports:
|
|
8
|
+
|
|
9
|
+
- **ISO8601 timestamps**: Standard datetime strings
|
|
10
|
+
- **Relative times**: Human-readable time expressions like "1d", "3h", "30m"
|
|
11
|
+
- **Flexible times**: Either ISO8601 or relative time
|
|
12
|
+
- **Bounded times**: Min/max constraints on time values
|
|
13
|
+
- **Direction-aware**: Parse relative times as past or future
|
|
14
|
+
|
|
15
|
+
## Core Schemas
|
|
16
|
+
|
|
17
|
+
### Validation-Only Schemas
|
|
18
|
+
|
|
19
|
+
These schemas validate format but don't transform the value:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { ISO8601Schema } from './iso8601.schema';
|
|
23
|
+
import { RelativeTimeSchema } from './relative-time.schema';
|
|
24
|
+
import { FlexibleTimeSchema } from './flexible-time.schema';
|
|
25
|
+
|
|
26
|
+
// Validates ISO8601 format
|
|
27
|
+
ISO8601Schema.parse("2024-01-15T12:00:00Z"); // ✓ returns same string
|
|
28
|
+
|
|
29
|
+
// Validates relative time format
|
|
30
|
+
RelativeTimeSchema.parse("1d"); // ✓ returns "1d"
|
|
31
|
+
|
|
32
|
+
// Validates either format
|
|
33
|
+
FlexibleTimeSchema.parse("2024-01-15T12:00:00Z"); // ✓
|
|
34
|
+
FlexibleTimeSchema.parse("3h"); // ✓
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Transformation Schemas
|
|
38
|
+
|
|
39
|
+
These schemas validate AND transform to ISO8601:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import {
|
|
43
|
+
RelativeTimePastSchema,
|
|
44
|
+
RelativeTimeFutureSchema,
|
|
45
|
+
} from './relative-time.schema';
|
|
46
|
+
import {
|
|
47
|
+
FlexibleTimePastSchema,
|
|
48
|
+
FlexibleTimeFutureSchema,
|
|
49
|
+
} from './flexible-time.schema';
|
|
50
|
+
|
|
51
|
+
// Transform relative time to ISO8601 (subtracting from now)
|
|
52
|
+
RelativeTimePastSchema.parse("1d");
|
|
53
|
+
// → "2024-01-14T12:00:00.000Z" (if now is 2024-01-15T12:00:00Z)
|
|
54
|
+
|
|
55
|
+
// Transform relative time to ISO8601 (adding to now)
|
|
56
|
+
RelativeTimeFutureSchema.parse("2h");
|
|
57
|
+
// → "2024-01-15T14:00:00.000Z"
|
|
58
|
+
|
|
59
|
+
// Transform flexible time (past direction for relatives)
|
|
60
|
+
FlexibleTimePastSchema.parse("1d"); // → "2024-01-14T12:00:00.000Z"
|
|
61
|
+
FlexibleTimePastSchema.parse("2024-01-15T12:00:00Z"); // → "2024-01-15T12:00:00.000Z"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Bounded Schemas
|
|
65
|
+
|
|
66
|
+
Create schemas with min/max time boundaries:
|
|
67
|
+
|
|
68
|
+
### Flexible Time with Boundaries
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { createBoundedFlexibleTimeSchema } from './bounded-time.schema';
|
|
72
|
+
|
|
73
|
+
// Accept times between 1 week ago and 1 month in future
|
|
74
|
+
const schema = createBoundedFlexibleTimeSchema({
|
|
75
|
+
min: "1w", // Oldest allowed: 1 week ago
|
|
76
|
+
max: "1M", // Newest allowed: 1 month in future
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
schema.parse("3d"); // ✓ (3 days ago, within range)
|
|
80
|
+
schema.parse("2w"); // ✗ (too old, more than 1w ago)
|
|
81
|
+
schema.parse("2024-01-20T12:00:00Z"); // ✓ (if within range)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Relative Time with Boundaries
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { createBoundedRelativeTimeSchema } from './bounded-time.schema';
|
|
88
|
+
|
|
89
|
+
// Accept relative times between 1 hour and 30 days ago
|
|
90
|
+
const schema = createBoundedRelativeTimeSchema({
|
|
91
|
+
min: "1h", // Most recent: 1 hour ago
|
|
92
|
+
max: "30d", // Oldest: 30 days ago
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
schema.parse("3d"); // ✓ (3 days ago, within range)
|
|
96
|
+
schema.parse("30m"); // ✗ (too recent, less than 1h ago)
|
|
97
|
+
schema.parse("60d"); // ✗ (too old, more than 30d ago)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Helper Functions
|
|
101
|
+
|
|
102
|
+
### parseRelativeTime
|
|
103
|
+
|
|
104
|
+
Parse relative time expression to ISO8601:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { parseRelativeTime } from './time-helpers';
|
|
108
|
+
|
|
109
|
+
// Parse relative to a specific date
|
|
110
|
+
const base = new Date("2024-01-15T12:00:00Z");
|
|
111
|
+
|
|
112
|
+
parseRelativeTime("1d", base, "past");
|
|
113
|
+
// → "2024-01-14T12:00:00.000Z"
|
|
114
|
+
|
|
115
|
+
parseRelativeTime("2h", base, "future");
|
|
116
|
+
// → "2024-01-15T14:00:00.000Z"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### parseFlexibleTime
|
|
120
|
+
|
|
121
|
+
Parse flexible time (ISO8601 or relative) to ISO8601:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import { parseFlexibleTime } from './time-helpers';
|
|
125
|
+
|
|
126
|
+
parseFlexibleTime("2024-01-15T12:00:00Z");
|
|
127
|
+
// → "2024-01-15T12:00:00.000Z"
|
|
128
|
+
|
|
129
|
+
parseFlexibleTime("1d", new Date(), "past");
|
|
130
|
+
// → ISO8601 string for 1 day ago
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Usage Examples
|
|
134
|
+
|
|
135
|
+
### API Query Parameter
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import { FlexibleTimePastSchema } from './flexible-time.schema';
|
|
139
|
+
|
|
140
|
+
const QuerySchema = z.object({
|
|
141
|
+
since: FlexibleTimePastSchema.describe("Start time for query"),
|
|
142
|
+
until: FlexibleTimePastSchema.optional(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Accepts both:
|
|
146
|
+
// ?since=2024-01-15T12:00:00Z
|
|
147
|
+
// ?since=3d (3 days ago)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Bounded Time Range
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import { createBoundedFlexibleTimeSchema } from './bounded-time.schema';
|
|
154
|
+
|
|
155
|
+
const UpdateQuerySchema = z.object({
|
|
156
|
+
updatedAfter: createBoundedFlexibleTimeSchema({
|
|
157
|
+
min: "1w", // Max 1 week in the past
|
|
158
|
+
max: "0h", // Up to now
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Log Retention Filter
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
import { createBoundedRelativeTimeSchema } from './bounded-time.schema';
|
|
167
|
+
|
|
168
|
+
const LogQuerySchema = z.object({
|
|
169
|
+
since: createBoundedRelativeTimeSchema({
|
|
170
|
+
min: "1h", // At least 1 hour ago
|
|
171
|
+
max: "90d", // At most 90 days ago
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Type Definitions
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// Core types
|
|
180
|
+
type ISO8601 = string; // ISO8601 datetime string
|
|
181
|
+
type RelativeTime = string; // e.g., "1d", "3h", "30m"
|
|
182
|
+
type FlexibleTime = ISO8601 | RelativeTime;
|
|
183
|
+
|
|
184
|
+
// Boundary options
|
|
185
|
+
interface BoundaryOptions {
|
|
186
|
+
min?: string; // Minimum allowed time (relative or ISO)
|
|
187
|
+
max?: string; // Maximum allowed time (relative or ISO)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Time Units
|
|
193
|
+
|
|
194
|
+
Supported time units in relative time expressions:
|
|
195
|
+
|
|
196
|
+
- `m`: minutes (e.g., "30m" = 30 minutes)
|
|
197
|
+
- `h`: hours (e.g., "2h" = 2 hours)
|
|
198
|
+
- `d`: days (e.g., "3d" = 3 days)
|
|
199
|
+
- `w`: weeks (e.g., "1w" = 1 week)
|
|
200
|
+
- `M`: months (e.g., "2M" = 2 months, using calendar months)
|
|
201
|
+
|
|
202
|
+
## Best Practices
|
|
203
|
+
|
|
204
|
+
1. **Use transformation schemas for API inputs**: They automatically convert to ISO8601
|
|
205
|
+
2. **Use bounded schemas for security**: Prevent excessive historical or future queries
|
|
206
|
+
3. **Prefer FlexibleTime for user inputs**: Allows both absolute and relative times
|
|
207
|
+
4. **Use RelativeTime for time-based rules**: When only relative expressions make sense
|
|
208
|
+
5. **Add clear error messages**: Help users understand time constraints
|
|
209
|
+
6. **Import from atomic files**: More efficient bundling and clearer dependencies
|
|
210
|
+
|
|
211
|
+
## Dependencies
|
|
212
|
+
|
|
213
|
+
- **dayjs**: Lightweight date manipulation library
|
|
214
|
+
- **zod**: TypeScript-first schema validation
|
|
215
|
+
|
|
216
|
+
## See Also
|
|
217
|
+
|
|
218
|
+
- `flexible-time-parser.ts`: Advanced parser with week notation support
|
|
219
|
+
- `boundary-enforcement.ts`: Helper for clamping times to boundaries
|
|
220
|
+
- `iso8601.types.ts`: Legacy ISO8601 schema (deprecated)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { clampToBoundaries } from "./boundary-enforcement";
|
|
4
|
+
|
|
5
|
+
describe("clampToBoundaries", () => {
|
|
6
|
+
const now = new Date("2024-01-15T12:00:00.000Z");
|
|
7
|
+
|
|
8
|
+
describe("within boundaries", () => {
|
|
9
|
+
it("should not clamp timestamp within boundaries (-1w to +12m)", () => {
|
|
10
|
+
const timestamp = "2024-01-10T12:00:00.000Z"; // 5 days ago
|
|
11
|
+
const result = clampToBoundaries(timestamp, "testField", now);
|
|
12
|
+
|
|
13
|
+
expect(result.clamped).toBe(timestamp);
|
|
14
|
+
expect(result.wasClamped).toBe(false);
|
|
15
|
+
expect(result.originalValue).toBe(timestamp);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should not clamp timestamp at minimum boundary (-1w)", () => {
|
|
19
|
+
const timestamp = "2024-01-08T12:00:00.000Z"; // Exactly 1 week ago
|
|
20
|
+
const result = clampToBoundaries(timestamp, "testField", now);
|
|
21
|
+
|
|
22
|
+
expect(result.clamped).toBe(timestamp);
|
|
23
|
+
expect(result.wasClamped).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should not clamp timestamp at maximum boundary (+12m)", () => {
|
|
27
|
+
const timestamp = "2025-01-15T12:00:00.000Z"; // Exactly 12 months in future
|
|
28
|
+
const result = clampToBoundaries(timestamp, "testField", now);
|
|
29
|
+
|
|
30
|
+
expect(result.clamped).toBe(timestamp);
|
|
31
|
+
expect(result.wasClamped).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should not clamp timestamp at current time", () => {
|
|
35
|
+
const timestamp = now.toISOString();
|
|
36
|
+
const result = clampToBoundaries(timestamp, "testField", now);
|
|
37
|
+
|
|
38
|
+
expect(result.clamped).toBe(timestamp);
|
|
39
|
+
expect(result.wasClamped).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("minimum boundary clamping", () => {
|
|
44
|
+
it("should clamp timestamp before minimum boundary (-1w)", () => {
|
|
45
|
+
const timestamp = "2024-01-01T12:00:00.000Z"; // 2 weeks ago
|
|
46
|
+
const result = clampToBoundaries(timestamp, "testField", now);
|
|
47
|
+
|
|
48
|
+
expect(result.wasClamped).toBe(true);
|
|
49
|
+
expect(result.originalValue).toBe(timestamp);
|
|
50
|
+
expect(new Date(result.clamped).getTime()).toBeGreaterThan(
|
|
51
|
+
new Date(timestamp).getTime()
|
|
52
|
+
);
|
|
53
|
+
expect(result.clamped).toBe("2024-01-08T12:00:00.000Z"); // Clamped to 1 week ago
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should clamp very old timestamp to minimum boundary", () => {
|
|
57
|
+
const timestamp = "2020-01-01T12:00:00.000Z"; // Years ago
|
|
58
|
+
const result = clampToBoundaries(timestamp, "testField", now);
|
|
59
|
+
|
|
60
|
+
expect(result.wasClamped).toBe(true);
|
|
61
|
+
expect(result.clamped).toBe("2024-01-08T12:00:00.000Z");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("maximum boundary clamping", () => {
|
|
66
|
+
it("should clamp timestamp after maximum boundary (+12m)", () => {
|
|
67
|
+
const timestamp = "2025-02-15T12:00:00.000Z"; // 13 months in future
|
|
68
|
+
const result = clampToBoundaries(timestamp, "testField", now);
|
|
69
|
+
|
|
70
|
+
expect(result.wasClamped).toBe(true);
|
|
71
|
+
expect(result.originalValue).toBe(timestamp);
|
|
72
|
+
expect(new Date(result.clamped).getTime()).toBeLessThan(
|
|
73
|
+
new Date(timestamp).getTime()
|
|
74
|
+
);
|
|
75
|
+
expect(result.clamped).toBe("2025-01-15T12:00:00.000Z"); // Clamped to 12 months ahead
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should clamp far future timestamp to maximum boundary", () => {
|
|
79
|
+
const timestamp = "2030-01-01T12:00:00.000Z"; // Years in future
|
|
80
|
+
const result = clampToBoundaries(timestamp, "testField", now);
|
|
81
|
+
|
|
82
|
+
expect(result.wasClamped).toBe(true);
|
|
83
|
+
expect(result.clamped).toBe("2025-01-15T12:00:00.000Z");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("default parameters", () => {
|
|
88
|
+
it("should use current time when 'now' is not provided", () => {
|
|
89
|
+
const timestamp = "2024-01-10T12:00:00.000Z";
|
|
90
|
+
const result = clampToBoundaries(timestamp, "testField");
|
|
91
|
+
|
|
92
|
+
expect(result).toHaveProperty("clamped");
|
|
93
|
+
expect(result).toHaveProperty("wasClamped");
|
|
94
|
+
expect(result).toHaveProperty("originalValue");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("edge cases", () => {
|
|
99
|
+
it("should handle timestamps with milliseconds", () => {
|
|
100
|
+
const timestamp = "2024-01-10T12:00:00.123Z";
|
|
101
|
+
const result = clampToBoundaries(timestamp, "testField", now);
|
|
102
|
+
|
|
103
|
+
expect(result.wasClamped).toBe(false);
|
|
104
|
+
expect(result.clamped).toBe(timestamp);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should handle timestamps in different formats", () => {
|
|
108
|
+
const timestamp = "2024-01-10T12:00:00+00:00";
|
|
109
|
+
const result = clampToBoundaries(timestamp, "testField", now);
|
|
110
|
+
|
|
111
|
+
expect(result.wasClamped).toBe(false);
|
|
112
|
+
expect(new Date(result.clamped).getTime()).toBe(
|
|
113
|
+
new Date(timestamp).getTime()
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("field name parameter", () => {
|
|
119
|
+
it("should accept any field name for logging purposes", () => {
|
|
120
|
+
const timestamp = "2024-01-01T12:00:00.000Z";
|
|
121
|
+
const result = clampToBoundaries(
|
|
122
|
+
timestamp,
|
|
123
|
+
"customFieldName",
|
|
124
|
+
now
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(result.wasClamped).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { getLogger } from "@schafevormfenster/logging";
|
|
2
|
+
import dayjs from "dayjs";
|
|
3
|
+
import utc from "dayjs/plugin/utc.js";
|
|
4
|
+
|
|
5
|
+
dayjs.extend(utc);
|
|
6
|
+
|
|
7
|
+
const log = getLogger("helpers.time.boundary-enforcement");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Helper function to clamp a timestamp to the allowed boundaries (-1w to +12m from now)
|
|
11
|
+
* Returns the clamped ISO8601 timestamp and a boolean indicating if clamping occurred
|
|
12
|
+
* All calculations are performed in UTC timezone
|
|
13
|
+
*/
|
|
14
|
+
export function clampToBoundaries(
|
|
15
|
+
timestamp: string,
|
|
16
|
+
fieldName: string,
|
|
17
|
+
now: Date = new Date()
|
|
18
|
+
): { clamped: string; wasClamped: boolean; originalValue: string } {
|
|
19
|
+
const timestampMs = new Date(timestamp).getTime();
|
|
20
|
+
|
|
21
|
+
// Calculate boundaries using Day.js in UTC
|
|
22
|
+
const minBoundaryMs = dayjs.utc(now).subtract(1, "week").valueOf();
|
|
23
|
+
const maxBoundaryMs = dayjs.utc(now).add(12, "month").valueOf();
|
|
24
|
+
|
|
25
|
+
let clampedMs = timestampMs;
|
|
26
|
+
let wasClamped = false;
|
|
27
|
+
|
|
28
|
+
if (timestampMs < minBoundaryMs) {
|
|
29
|
+
clampedMs = minBoundaryMs;
|
|
30
|
+
wasClamped = true;
|
|
31
|
+
log.warn(
|
|
32
|
+
{
|
|
33
|
+
field: fieldName,
|
|
34
|
+
originalValue: timestamp,
|
|
35
|
+
clampedValue: new Date(clampedMs).toISOString(),
|
|
36
|
+
boundary: "minimum (-1w)",
|
|
37
|
+
},
|
|
38
|
+
`Calendar update query ${fieldName} clamped to minimum boundary`
|
|
39
|
+
);
|
|
40
|
+
} else if (timestampMs > maxBoundaryMs) {
|
|
41
|
+
clampedMs = maxBoundaryMs;
|
|
42
|
+
wasClamped = true;
|
|
43
|
+
log.warn(
|
|
44
|
+
{
|
|
45
|
+
field: fieldName,
|
|
46
|
+
originalValue: timestamp,
|
|
47
|
+
clampedValue: new Date(clampedMs).toISOString(),
|
|
48
|
+
boundary: "maximum (+12m)",
|
|
49
|
+
},
|
|
50
|
+
`Calendar update query ${fieldName} clamped to maximum boundary`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
clamped: new Date(clampedMs).toISOString(),
|
|
56
|
+
wasClamped,
|
|
57
|
+
originalValue: timestamp,
|
|
58
|
+
};
|
|
59
|
+
}
|