LCOV - code coverage report
Current view: top level - domain/entity - user_address.go Coverage Total Hit
Test: coverage.lcov Lines: 0.0 % 115 0
Test Date: 2026-04-14 06:42:22 Functions: - 0 0

            Line data    Source code
       1              : // Package entity はフリーランサーの住所を表すドメインエンティティです
       2              : package entity
       3              : 
       4              : import (
       5              :         "errors"
       6              :         "regexp"
       7              :         "strings"
       8              :         "time"
       9              : 
      10              :         "gorm.io/gorm"
      11              : 
      12              :         "resume/internal/shared/jpstring"
      13              : )
      14              : 
      15              : // UserAddress はフリーランサーの住所を表すドメインエンティティです
      16              : type UserAddress struct {
      17              :         ID        uint64 `gorm:"primaryKey;autoIncrement"`
      18              :         UserID    uint64 `gorm:"not null;index:idx_user_primary,priority:1"`               // users.id FK
      19              :         PurposeID uint64 `gorm:"not null;index:uq_user_purpose_primary,unique,priority:2"` // address_purposes.id FK
      20              : 
      21              :         IsPrimary bool `gorm:"not null;default:false;index:idx_user_primary,priority:2"`
      22              : 
      23              :         CountryCode        string  `gorm:"type:char(2);not null;index:idx_country_postal,priority:1"`
      24              :         AdministrativeArea *string `gorm:"size:128;index:idx_loc,priority:2"`
      25              :         Locality           *string `gorm:"size:128;index:idx_loc,priority:3"`
      26              :         DependentLocality  *string `gorm:"size:128"`
      27              :         PostalCode         *string `gorm:"size:32;index:idx_country_postal,priority:2"`
      28              :         SortingCode        *string `gorm:"size:32"`
      29              : 
      30              :         AddressLine1 string  `gorm:"size:160;not null"`
      31              :         AddressLine2 *string `gorm:"size:160"`
      32              :         AddressLine3 *string `gorm:"size:160"`
      33              : 
      34              :         Organization string `gorm:"size:160"`
      35              :         GivenName    string `gorm:"size:80"`
      36              :         FamilyName   string `gorm:"size:80"`
      37              : 
      38              :         LanguageCode string   `gorm:"size:35"`
      39              :         Latitude     *float64 `gorm:"type:decimal(9,6)"`
      40              :         Longitude    *float64 `gorm:"type:decimal(9,6)"`
      41              : 
      42              :         CreatedAt time.Time      `gorm:"autoCreateTime"`
      43              :         UpdatedAt time.Time      `gorm:"autoUpdateTime"`
      44              :         DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
      45              : 
      46              :         // 関連
      47              :         Purpose *AddressPurpose `gorm:"foreignKey:PurposeID;references:ID"`
      48              : }
      49              : 
      50              : // TableName は GORM のテーブル名を返します。
      51            0 : func (UserAddress) TableName() string {
      52            0 :         return "user_addresses"
      53            0 : }
      54              : 
      55              : // UserAddressParam は UserAddress を生成するための入力値を表します。
      56              : // バリデーションは NewUserAddress 内で行われ、無効な場合はエラーを返します。
      57              : type UserAddressParam struct {
      58              :         UserID, PurposeID                                                        uint64
      59              :         IsPrimary                                                                bool
      60              :         CountryCode                                                              string
      61              :         AdministrativeArea, Locality, DependentLocality, PostalCode, SortingCode *string
      62              :         AddressLine1                                                             string
      63              :         AddressLine2, AddressLine3                                               *string
      64              :         Latitude, Longitude                                                      *float64
      65              : }
      66              : 
      67              : // NewUserAddress は UserAddress エンティティを生成します。
      68              : // 与えられた UserAddressParam が無効な場合は ErrInvalidAddress などを返します。
      69            0 : func NewUserAddress(p UserAddressParam) (*UserAddress, error) {
      70            0 : 
      71            0 :         if (p.Latitude != nil && p.Longitude == nil) || (p.Latitude == nil && p.Longitude != nil) {
      72            0 :                 return nil, errors.Join(ErrInvalidAddress, errCoordPair)
      73            0 :         }
      74              : 
      75            0 :         if p.Latitude != nil && (*p.Latitude < -90 || *p.Latitude > 90) {
      76            0 :                 return nil, errors.Join(ErrInvalidAddress, errLatRange)
      77            0 :         }
      78              : 
      79            0 :         if p.Longitude != nil && (*p.Longitude < -180 || *p.Longitude > 180) {
      80            0 :                 return nil, errors.Join(ErrInvalidAddress, errLngRange)
      81            0 :         }
      82              : 
      83            0 :         if p.CountryCode == "" {
      84            0 :                 return nil, errors.Join(ErrInvalidAddress, errCountryCode)
      85            0 :         }
      86              : 
      87            0 :         if p.CountryCode == "JP" {
      88            0 :                 if p.AdministrativeArea == nil || *p.AdministrativeArea == "" {
      89            0 :                         return nil, errors.Join(ErrInvalidAddress, errJPPrefRequired)
      90            0 :                 }
      91            0 :                 if !jpstring.IsValidPrefecture(*p.AdministrativeArea) {
      92            0 :                         return nil, errors.Join(ErrInvalidAddress, errJPPrefInvalid)
      93            0 :                 }
      94              :         }
      95              : 
      96            0 :         if p.AddressLine1 == "" {
      97            0 :                 return nil, errors.Join(ErrInvalidAddress, errAddressLine1Required)
      98            0 :         }
      99              : 
     100            0 :         var probs []FieldError
     101            0 : 
     102            0 :         // 標準化
     103            0 :         cc := strings.ToUpper(strings.TrimSpace(p.CountryCode))
     104            0 :         if len(cc) != 2 {
     105            0 :                 probs = append(probs, FieldError{"countryCode", "len", "2"})
     106            0 :         }
     107              : 
     108              :         // addressLine1 必須(最終防衛)
     109            0 :         if strings.TrimSpace(p.AddressLine1) == "" {
     110            0 :                 probs = append(probs, FieldError{"addressLine1", "required", ""})
     111            0 :         }
     112              : 
     113              :         // lat/lng のペア & range
     114            0 :         if (p.Latitude == nil) != (p.Longitude == nil) {
     115            0 :                 probs = append(probs,
     116            0 :                         FieldError{"latitude", "coordpair", "both_or_none"},
     117            0 :                         FieldError{"longitude", "coordpair", "both_or_none"},
     118            0 :                 )
     119            0 :         } else if p.Latitude != nil && p.Longitude != nil {
     120            0 :                 if *p.Latitude < -90 || *p.Latitude > 90 {
     121            0 :                         probs = append(probs, FieldError{"latitude", "range", "[-90,90]"})
     122            0 :                 }
     123            0 :                 if *p.Longitude < -180 || *p.Longitude > 180 {
     124            0 :                         probs = append(probs, FieldError{"longitude", "range", "[-180,180]"})
     125            0 :                 }
     126              :         }
     127              : 
     128              :         // JP 固有
     129            0 :         if cc == "JP" {
     130            0 :                 // 都道府県必須 + 妥当性
     131            0 :                 if p.AdministrativeArea == nil || strings.TrimSpace(*p.AdministrativeArea) == "" {
     132            0 :                         probs = append(probs, FieldError{"administrativeArea", "jppref_required", "required_when:countryCode=JP"})
     133            0 :                 } else {
     134            0 :                         name := jpstring.NormalizeSpaces(*p.AdministrativeArea)
     135            0 :                         if !jpstring.IsValidPrefecture(name) {
     136            0 :                                 probs = append(probs, FieldError{"administrativeArea", "jpref", ""})
     137            0 :                         } else {
     138            0 :                                 // 正規化して反映(スペース整形)
     139            0 :                                 p.AdministrativeArea = &name
     140            0 :                         }
     141              :                 }
     142              :                 // 郵便番号:存在すれば書式チェック(全角→半角・ハイフン統一後)
     143            0 :                 if p.PostalCode != nil && strings.TrimSpace(*p.PostalCode) != "" {
     144            0 :                         norm := jpstring.NormalizeDigitsHyphen(*p.PostalCode)
     145            0 :                         if !rePostalJP.MatchString(norm) {
     146            0 :                                 probs = append(probs, FieldError{"postalCode", "jppostal", ""})
     147            0 :                         } else {
     148            0 :                                 // 保存形を正規化に寄せたいならここで反映
     149            0 :                                 p.PostalCode = &norm
     150            0 :                         }
     151              :                 }
     152              :         }
     153              : 
     154            0 :         if len(probs) > 0 {
     155            0 :                 return nil, InvalidAddressError{Problems: probs}
     156            0 :         }
     157              : 
     158              :         // 住所文字列の軽い正規化(任意。UI側ですでにやっていれば薄くてもOK)
     159            0 :         addr1 := jpstring.NormalizeJP(strings.TrimSpace(p.AddressLine1))
     160            0 :         var addr2, addr3 *string
     161            0 :         if p.AddressLine2 != nil {
     162            0 :                 s := jpstring.NormalizeJP(strings.TrimSpace(*p.AddressLine2))
     163            0 :                 addr2 = &s
     164            0 :         }
     165            0 :         if p.AddressLine3 != nil {
     166            0 :                 s := jpstring.NormalizeJP(strings.TrimSpace(*p.AddressLine3))
     167            0 :                 addr3 = &s
     168            0 :         }
     169            0 :         var locality, depLoc *string
     170            0 :         if p.Locality != nil {
     171            0 :                 s := jpstring.NormalizeJP(strings.TrimSpace(*p.Locality))
     172            0 :                 locality = &s
     173            0 :         }
     174            0 :         if p.DependentLocality != nil {
     175            0 :                 s := jpstring.NormalizeJP(strings.TrimSpace(*p.DependentLocality))
     176            0 :                 depLoc = &s
     177            0 :         }
     178              :         // ここで不変条件を最終チェック(例: len(CountryCode)==2、lat/lngのペア等)
     179            0 :         a := &UserAddress{
     180            0 :                 UserID:    p.UserID,
     181            0 :                 PurposeID: p.PurposeID,
     182            0 :                 IsPrimary: p.IsPrimary,
     183            0 : 
     184            0 :                 CountryCode:        cc, // ← cc を使う
     185            0 :                 AdministrativeArea: p.AdministrativeArea,
     186            0 :                 Locality:           locality, // ← 正規化版
     187            0 :                 DependentLocality:  depLoc,   // ← 正規化版
     188            0 :                 PostalCode:         p.PostalCode,
     189            0 :                 SortingCode:        p.SortingCode,
     190            0 : 
     191            0 :                 AddressLine1: addr1, // ← 正規化版
     192            0 :                 AddressLine2: addr2, // ← 正規化版
     193            0 :                 AddressLine3: addr3, // ← 正規化版
     194            0 : 
     195            0 :                 Latitude:  p.Latitude,
     196            0 :                 Longitude: p.Longitude,
     197            0 :         }
     198            0 :         return a, nil
     199              : }
     200              : 
     201              : var (
     202              :         rePostalJP = regexp.MustCompile(`^\d{3}-?\d{4}$`)
     203              : 
     204              :         // ErrInvalidAddress は住所エンティティの生成/検証に失敗したことを示すエラーです。
     205              :         // errors.Is(err, ErrInvalidAddress) で判定できます。
     206              :         ErrInvalidAddress = errors.New("invalid address")
     207              : 
     208              :         // ErrCoordPair は緯度と経度が「両方指定」または「両方未指定」の条件を満たしていないことを示すエラーです。
     209              :         // 片方のみ指定された場合に発生します。
     210              :         errCoordPair            = errors.New("latitude/longitude must be both set or both nil")
     211              :         errLatRange             = errors.New("latitude out of range")
     212              :         errLngRange             = errors.New("longitude out of range")
     213              :         errCountryCode          = errors.New("invalid country code")
     214              :         errJPPrefRequired       = errors.New("administrativeArea required for JP")
     215              :         errJPPrefInvalid        = errors.New("invalid Japanese prefecture")
     216              :         errAddressLine1Required = errors.New("addressLine1 required")
     217              : )
     218              : 
     219              : // FieldError は フィールド単位のエラー情報
     220              : type FieldError struct {
     221              :         Field string // JSON名で統一: "countryCode", "latitude" など
     222              :         Tag   string // "required", "coordpair", "range", "jppostal", "jpref_required" etc.
     223              :         Param string // 追加情報("both_or_none", "[-90,90]", "required_when:countryCode=JP" 等)
     224              : }
     225              : 
     226              : // InvalidAddressError は住所が無効である場合の詳細を保持するエラー型です。
     227              : // フィールド単位の理由や原因となる値など、追加情報を含められます。
     228              : type InvalidAddressError struct {
     229              :         Problems []FieldError
     230              : }
     231              : 
     232            0 : func (e InvalidAddressError) Error() string { return ErrInvalidAddress.Error() }
     233              : 
     234              : // Is は errors.Is(e, target) の判定で ErrInvalidAddress と同一視できるようにします。
     235              : // これにより、呼び出し側は詳細型に依存せず「無効な住所」エラーを判定できます。
     236            0 : func (e InvalidAddressError) Is(target error) bool {
     237            0 :         return target == ErrInvalidAddress
     238            0 : }
        

Generated by: LCOV version 2.3.1-1