feat(shared): zod şemaları, Türkçe arama ve TL/tarih biçimleri

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 01:35:10 +03:00
parent c875847b65
commit 4dbd2eb441
11 changed files with 504 additions and 3 deletions
View File
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { formatDate, formatDateTime, formatMoney } from "./format";
// Intl çıktısındaki boşluklar (ör. U+00A0) ortama göre değişebilir; karşılaştırmada normalleştir.
const norm = (s: string) => s.replace(/\s/g, " ");
describe("formatMoney", () => {
it("kuruşu TL olarak Türkçe biçimde gösterir", () => {
expect(norm(formatMoney(125050))).toBe("₺1.250,50");
expect(norm(formatMoney(0))).toBe("₺0,00");
expect(norm(formatMoney(-9999))).toBe("-₺99,99");
});
it("tamsayı olmayan tutarı reddeder", () => {
expect(() => formatMoney(12.5)).toThrow(TypeError);
});
});
describe("formatDate", () => {
it("İstanbul saatine göre gün sınırını uygular", () => {
// 31.12.2026 21:30 UTC = 01.01.2027 00:30 İstanbul (UTC+3)
expect(formatDate("2026-12-31T21:30:00Z")).toBe("01.01.2027");
expect(formatDate("2026-12-31T20:59:00Z")).toBe("31.12.2026");
});
it("tarih-saat biçimi", () => {
expect(norm(formatDateTime("2026-11-05T07:05:00Z"))).toBe("05.11.2026 10:05");
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Görüntüleme biçimleri (tr-TR, Europe/Istanbul). Sunucu ve istemci aynı sonucu üretir
* çünkü yerel ayar ve saat dilimi her zaman açıkça verilir (hydration uyumsuzluğu olmaz).
*/
export const LOCALE = "tr-TR";
export const TIME_ZONE = "Europe/Istanbul";
const moneyFmt = new Intl.NumberFormat(LOCALE, { style: "currency", currency: "TRY" });
/** Kuruş cinsinden tamsayı tutarı TL olarak biçimlendirir: 125050 → "₺1.250,50". */
export function formatMoney(amountMinor: number): string {
if (!Number.isSafeInteger(amountMinor)) {
throw new TypeError(`amountMinor tamsayı olmalı: ${amountMinor}`);
}
return moneyFmt.format(amountMinor / 100);
}
const dateFmt = new Intl.DateTimeFormat(LOCALE, {
timeZone: TIME_ZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const dateTimeFmt = new Intl.DateTimeFormat(LOCALE, {
timeZone: TIME_ZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
/** UTC tarihi İstanbul saatine göre "dd.MM.yyyy" biçiminde gösterir. */
export function formatDate(date: Date | string): string {
return dateFmt.format(new Date(date));
}
/** UTC tarihi İstanbul saatine göre "dd.MM.yyyy HH:mm" biçiminde gösterir. */
export function formatDateTime(date: Date | string): string {
return dateTimeFmt.format(new Date(date));
}
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
/** Giriş formu: kullanıcı adı veya e-posta + şifre. Biçim kontrolü sunucuda yapılır. */
export const loginSchema = z.object({
identifier: z.string().trim().min(1, tr.auth.identifierRequired),
password: z.string().min(1, tr.auth.passwordRequired),
});
export type LoginInput = z.infer<typeof loginSchema>;
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
const v = tr.validation;
/** Sistem rolleri (Better Auth admin plugin). */
export const SYSTEM_ROLES = ["admin", "user"] as const;
export const systemRoleSchema = z.enum(SYSTEM_ROLES);
export type SystemRole = z.infer<typeof systemRoleSchema>;
export const USERNAME_PATTERN = /^[a-z0-9._]{3,30}$/;
export const nameSchema = z.string().trim().min(2, v.nameMin).max(80, v.nameMax);
/** Kullanıcı adı küçük harfe çevrilir; Türkçe büyük İ/I sorununu önlemek için tr-TR kullanılır. */
export const usernameSchema = z
.string()
.trim()
.transform((s) => s.toLocaleLowerCase("tr-TR"))
.pipe(z.string().regex(USERNAME_PATTERN, v.usernameFormat));
export const emailSchema = z.string().trim().toLowerCase().pipe(z.email(v.email));
/** Boş bırakılabilen e-posta: "" → undefined. */
export const optionalEmailSchema = z
.string()
.trim()
.transform((s) => (s === "" ? undefined : s.toLowerCase()))
.pipe(z.email(v.email).optional());
export const passwordSchema = z.string().min(8, v.passwordMin).max(128, v.passwordMax);
/** Girilen değer e-posta mı (giriş sırasında e-posta/kullanıcı adı yöntemini seçmek için)? */
export function looksLikeEmail(value: string): boolean {
return value.includes("@");
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { loginSchema } from "./auth";
import { looksLikeEmail, usernameSchema } from "./common";
import { banUserSchema, userCreateSchema, userUpdateSchema } from "./user";
const base = {
name: "Ayşe Yılmaz",
username: "ayse",
email: "",
role: "user",
password: "gizli-1234",
};
describe("usernameSchema", () => {
it("küçük harfe çevirir ve boşlukları kırpar (Türkçe İ dahil)", () => {
expect(usernameSchema.parse(" AYSE.K ")).toBe("ayse.k");
expect(usernameSchema.parse("Ali_2")).toBe("ali_2");
});
it("Türkçe karakter, boşluk ve kısa değerleri reddeder", () => {
for (const bad of ["ayşe", "ali veli", "ab", "a".repeat(31), "ali-veli"]) {
expect(usernameSchema.safeParse(bad).success, bad).toBe(false);
}
});
});
describe("userCreateSchema", () => {
it("boş e-postayı undefined yapar", () => {
const out = userCreateSchema.parse(base);
expect(out.email).toBeUndefined();
expect(out.name).toBe("Ayşe Yılmaz");
});
it("e-postayı küçük harfe çevirir ve doğrular", () => {
expect(userCreateSchema.parse({ ...base, email: " Ayse@Ornek.COM " }).email).toBe(
"ayse@ornek.com",
);
expect(userCreateSchema.safeParse({ ...base, email: "gecersiz" }).success).toBe(false);
});
it("kısa şifreyi ve geçersiz rolü reddeder", () => {
expect(userCreateSchema.safeParse({ ...base, password: "1234567" }).success).toBe(false);
expect(userCreateSchema.safeParse({ ...base, role: "owner" }).success).toBe(false);
});
it("güncelleme şemasında şifre yoktur", () => {
const { password: _password, ...rest } = base;
expect(userUpdateSchema.parse(rest)).not.toHaveProperty("password");
});
});
describe("banUserSchema", () => {
it("boş nedeni undefined yapar, uzun nedeni reddeder", () => {
expect(banUserSchema.parse({ reason: " " }).reason).toBeUndefined();
expect(banUserSchema.safeParse({ reason: "x".repeat(201) }).success).toBe(false);
});
});
describe("loginSchema", () => {
it("boş alanları Türkçe mesajla reddeder", () => {
const r = loginSchema.safeParse({ identifier: " ", password: "" });
expect(r.success).toBe(false);
expect(r.error?.issues.map((i) => i.message)).toEqual([
"Kullanıcı adı veya e-posta girin",
"Şifre girin",
]);
});
it("e-posta / kullanıcı adı ayrımı", () => {
expect(looksLikeEmail("ayse@ornek.com")).toBe(true);
expect(looksLikeEmail("ayse")).toBe(false);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import {
nameSchema,
optionalEmailSchema,
passwordSchema,
systemRoleSchema,
usernameSchema,
} from "./common";
/** Admin: yeni kullanıcı. E-posta isteğe bağlı (çocuklar vb. için kullanıcı adıyla giriş). */
export const userCreateSchema = z.object({
name: nameSchema,
username: usernameSchema,
email: optionalEmailSchema,
role: systemRoleSchema,
password: passwordSchema,
});
/** Admin: kullanıcı bilgisi güncelleme (şifre ayrı akışta). */
export const userUpdateSchema = userCreateSchema.omit({ password: true });
export const setPasswordSchema = z.object({ password: passwordSchema });
export const banUserSchema = z.object({
reason: z
.string()
.trim()
.max(200, tr.validation.banReasonMax)
.transform((s) => (s === "" ? undefined : s))
.optional(),
});
export type UserCreateInput = z.input<typeof userCreateSchema>;
export type UserCreate = z.output<typeof userCreateSchema>;
export type UserUpdateInput = z.input<typeof userUpdateSchema>;
export type UserUpdate = z.output<typeof userUpdateSchema>;
export type SetPasswordInput = z.input<typeof setPasswordSchema>;
export type BanUserInput = z.input<typeof banUserSchema>;
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { compareTr, foldTr, includesTr } from "./search";
describe("foldTr", () => {
it("Türkçe harfleri ve büyük/küçük farkını normalize eder", () => {
expect(foldTr("İrem")).toBe("irem");
expect(foldTr("IŞIK")).toBe("isik");
expect(foldTr("Ayşe Çağrı Öztürk Ünal")).toBe("ayse cagri ozturk unal");
});
});
describe("includesTr", () => {
it("aksansız ve büyük harfli aramayla eşleşir", () => {
expect(includesTr("İrem Güneş", "irem")).toBe(true);
expect(includesTr("İrem Güneş", "GUNES")).toBe(true);
expect(includesTr("Ayşe", "ayse")).toBe(true);
expect(includesTr("Ayşe", "mehmet")).toBe(false);
});
it("boş arama her şeyle eşleşir, boş değer hiçbir aramayla eşleşmez", () => {
expect(includesTr("x", " ")).toBe(true);
expect(includesTr(undefined, "a")).toBe(false);
});
});
describe("compareTr", () => {
it("Türk alfabesine göre sıralar", () => {
expect(["Zeynep", "Çiğdem", "Can", "Şule", "Selin", "İrem", "Ilgın"].sort(compareTr)).toEqual([
"Can",
"Çiğdem",
"Ilgın",
"İrem",
"Selin",
"Şule",
"Zeynep",
]);
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Türkçe arama normalizasyonu: büyük/küçük harf ve aksan farkını yok sayar.
* "İrem" ↔ "irem", "IŞIK" ↔ "isik", "Ayşe" ↔ "ayse", "Çağrı" ↔ "cagri".
*/
export function foldTr(value: string): string {
return value
.toLocaleLowerCase("tr-TR")
.replace(/ı/g, "i")
.normalize("NFD")
.replace(/\p{M}/gu, "")
.trim();
}
/** `haystack` içinde `needle` (normalize edilmiş) geçiyor mu? */
export function includesTr(haystack: string | undefined | null, needle: string): boolean {
if (!needle.trim()) return true;
return haystack ? foldTr(haystack).includes(foldTr(needle)) : false;
}
const collator = new Intl.Collator("tr-TR", { sensitivity: "base" });
/** Türkçe alfabetik sıralama karşılaştırıcısı (Ç, Ğ, İ, Ö, Ş, Ü doğru yerde). */
export function compareTr(a: string, b: string): number {
return collator.compare(a, b);
}