diff --git a/CHANGELOG.md b/CHANGELOG.md index b2918fb..a8b69e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- ISBN validation: `it.IsISBN()` with `Only10` / `Only13`, `validate.ISBN` with `validate.ISBNOnly10` / `validate.ISBNOnly13`, `is.ISBN`; `validation.ErrInvalidISBN`, `ErrInvalidISBN10`, `ErrInvalidISBN13` / `message.InvalidISBN`, `InvalidISBN10`, `InvalidISBN13` and English and Russian translations (behavior aligned with Symfony `Isbn`). - ISSN (International Standard Serial Number) validation: `it.IsISSN()`, `validate.ISSN`, `is.ISSN`, with `validation.ErrInvalidISSN` / `message.InvalidISSN` and English and Russian translations (ISO 3297 mod 11 check digit; optional hyphen; behavior aligned with Symfony `Issn`). - BIC / SWIFT validation: `it.IsBIC()` with `CaseInsensitive` and `WithIBAN` (and `WithIBANError` / `WithIBANMessage`), `validate.BIC` with `validate.BICCaseInsensitive` and `validate.BICWithIBAN`, `is.BIC`; `validation.ErrInvalidBIC`, `validation.ErrBICIBANCountryMismatch`, `message.InvalidBIC`, `message.BICNotAssociatedWithIBAN` and English and Russian translations (behavior aligned with Symfony `Bic` / `BicValidator`; country/territory check via `golang.org/x/text/language` regions plus Symfony’s BIC-to-IBAN territory map). - IBAN validation: `it.IsIBAN()`, `validate.IBAN`, `is.IBAN`, with `validation.ErrInvalidIBAN` / `message.InvalidIBAN` and English and Russian translations (behavior aligned with Symfony `Iban`; country patterns from Symfony 7.2 `IbanValidator`). diff --git a/errors.go b/errors.go index f38fb4d..09fe0e0 100644 --- a/errors.go +++ b/errors.go @@ -20,6 +20,9 @@ var ( ErrBICIBANCountryMismatch = NewError("BIC IBAN country mismatch", message.BICNotAssociatedWithIBAN) ErrInvalidISIN = NewError("invalid ISIN", message.InvalidISIN) ErrInvalidISSN = NewError("invalid ISSN", message.InvalidISSN) + ErrInvalidISBN = NewError("invalid ISBN", message.InvalidISBN) + ErrInvalidISBN10 = NewError("invalid ISBN-10", message.InvalidISBN10) + ErrInvalidISBN13 = NewError("invalid ISBN-13", message.InvalidISBN13) ErrInvalidCIDR = NewError("invalid CIDR", message.InvalidCIDR) ErrCIDRNetmaskOutOfRange = NewError("CIDR netmask out of range", message.CIDRNetmaskOutOfRange) ErrInvalidIP = NewError("invalid IP address", message.InvalidIP) diff --git a/is/example_test.go b/is/example_test.go index b658235..278ef33 100644 --- a/is/example_test.go +++ b/is/example_test.go @@ -309,6 +309,16 @@ func ExampleISSN() { // false } +func ExampleISBN() { + fmt.Println(is.ISBN("978-0451225245")) + fmt.Println(is.ISBN("978272344228")) + fmt.Println(is.ISBN("1234567890")) + // Output: + // true + // false + // false +} + func ExampleLUHN() { fmt.Println(is.LUHN("79927398713")) fmt.Println(is.LUHN("79927398710")) diff --git a/is/identifiers.go b/is/identifiers.go index c672ab5..8c0d2e2 100644 --- a/is/identifiers.go +++ b/is/identifiers.go @@ -40,6 +40,14 @@ func ISSN(value string) bool { return validate.ISSN(value) == nil } +// ISBN validates whether the value is a valid ISBN-10 or ISBN-13. +// See [github.com/muonsoft/validation/validate.ISBN] for options and possible errors. +// +// See https://en.wikipedia.org/wiki/ISBN. +func ISBN(value string, options ...func(*validate.ISBNOptions)) bool { + return validate.ISBN(value, options...) == nil +} + // LUHN validates whether the value passes the Luhn (mod 10) checksum. // See [github.com/muonsoft/validation/validate.LUHN] for validation rules and possible errors. // diff --git a/it/example_test.go b/it/example_test.go index a937993..2ed3c72 100644 --- a/it/example_test.go +++ b/it/example_test.go @@ -97,6 +97,20 @@ func ExampleIsISSN_invalid() { // violation: "This value is not a valid ISSN." } +func ExampleIsISBN_valid() { + err := validator.Validate(context.Background(), validation.String("978-0451225245", it.IsISBN())) + fmt.Println(err) + // Output: + // +} + +func ExampleIsISBN_invalid() { + err := validator.Validate(context.Background(), validation.String("978272344228", it.IsISBN())) + fmt.Println(err) + // Output: + // violation: "This value is neither a valid ISBN-10 nor a valid ISBN-13." +} + func ExampleIsLUHN_valid() { err := validator.Validate(context.Background(), validation.String("79927398713", it.IsLUHN())) fmt.Println(err) diff --git a/it/isbn.go b/it/isbn.go new file mode 100644 index 0000000..d8c5044 --- /dev/null +++ b/it/isbn.go @@ -0,0 +1,174 @@ +package it + +import ( + "context" + + "github.com/muonsoft/validation" + "github.com/muonsoft/validation/validate" +) + +type isbnMode int + +const ( + isbnModeAny isbnMode = iota + isbnMode10 + isbnMode13 +) + +// ISBNConstraint validates whether a string value is a valid ISBN-10 or ISBN-13. +// Hyphens (U+002D) are stripped before validation, matching Symfony Isbn. +// +// By default, either ISBN-10 or ISBN-13 is accepted. Use [ISBNConstraint.Only10] or +// [ISBNConstraint.Only13] to restrict the format. +// +// Empty values are skipped; combine with [NotBlank] or similar to reject empty strings. +// +// Behavior is aligned with Symfony\Component\Validator\Constraints\Isbn. +// +// See https://en.wikipedia.org/wiki/ISBN. +type ISBNConstraint struct { + isIgnored bool + groups []string + mode isbnMode + options []func(*validate.ISBNOptions) + err10 error + err13 error + errBoth error + message10 string + message13 string + messageBoth string + messageParams validation.TemplateParameterList +} + +// IsISBN validates whether the value is a valid ISBN-10 or ISBN-13. +func IsISBN() ISBNConstraint { + return ISBNConstraint{ + err10: validation.ErrInvalidISBN10, + err13: validation.ErrInvalidISBN13, + errBoth: validation.ErrInvalidISBN, + message10: validation.ErrInvalidISBN10.Message(), + message13: validation.ErrInvalidISBN13.Message(), + messageBoth: validation.ErrInvalidISBN.Message(), + } +} + +// Only10 restricts validation to ISBN-10 (Symfony Isbn::ISBN_10). +func (c ISBNConstraint) Only10() ISBNConstraint { + c.mode = isbnMode10 + c.options = append(c.options, validate.ISBNOnly10()) + return c +} + +// Only13 restricts validation to ISBN-13 (Symfony Isbn::ISBN_13). +func (c ISBNConstraint) Only13() ISBNConstraint { + c.mode = isbnMode13 + c.options = append(c.options, validate.ISBNOnly13()) + return c +} + +// WithError overrides the default errors for all ISBN modes. +func (c ISBNConstraint) WithError(err error) ISBNConstraint { + c.err10 = err + c.err13 = err + c.errBoth = err + return c +} + +// WithISBN10Error overrides the error used when validating as ISBN-10 only. +func (c ISBNConstraint) WithISBN10Error(err error) ISBNConstraint { + c.err10 = err + return c +} + +// WithISBN13Error overrides the error used when validating as ISBN-13 only. +func (c ISBNConstraint) WithISBN13Error(err error) ISBNConstraint { + c.err13 = err + return c +} + +// WithBothISBNError overrides the error used when either ISBN-10 or ISBN-13 is accepted. +func (c ISBNConstraint) WithBothISBNError(err error) ISBNConstraint { + c.errBoth = err + return c +} + +// WithMessage sets the message template for all ISBN modes. +func (c ISBNConstraint) WithMessage(template string, parameters ...validation.TemplateParameter) ISBNConstraint { + c.message10 = template + c.message13 = template + c.messageBoth = template + c.messageParams = parameters + return c +} + +// WithISBN10Message sets the message template for ISBN-10-only validation. +func (c ISBNConstraint) WithISBN10Message(template string, parameters ...validation.TemplateParameter) ISBNConstraint { + c.message10 = template + c.messageParams = parameters + return c +} + +// WithISBN13Message sets the message template for ISBN-13-only validation. +func (c ISBNConstraint) WithISBN13Message(template string, parameters ...validation.TemplateParameter) ISBNConstraint { + c.message13 = template + c.messageParams = parameters + return c +} + +// WithBothISBNMessage sets the message template when either format is accepted. +func (c ISBNConstraint) WithBothISBNMessage(template string, parameters ...validation.TemplateParameter) ISBNConstraint { + c.messageBoth = template + c.messageParams = parameters + return c +} + +// When enables conditional validation of this constraint. +func (c ISBNConstraint) When(condition bool) ISBNConstraint { + c.isIgnored = !condition + return c +} + +// WhenGroups enables conditional validation by validation groups. +func (c ISBNConstraint) WhenGroups(groups ...string) ISBNConstraint { + c.groups = groups + return c +} + +func (c ISBNConstraint) ValidateString(ctx context.Context, validator *validation.Validator, value *string) error { + if c.isIgnored || validator.IsIgnoredForGroups(c.groups...) || value == nil || *value == "" { + return nil + } + + err := validate.ISBN(*value, c.options...) + if err == nil { + return nil + } + + var vErr error + var msg string + + switch c.mode { + case isbnMode10: + vErr = c.err10 + msg = c.message10 + case isbnMode13: + vErr = c.err13 + msg = c.message13 + default: + vErr = c.errBoth + msg = c.messageBoth + } + + return validator.BuildViolation(ctx, vErr, msg). + WithParameters( + c.messageParams.Prepend( + validation.TemplateParameter{Key: "{{ value }}", Value: *value}, + )..., + ). + Create() +} + +// Validate implements [validation.Constraint][string]. +func (c ISBNConstraint) Validate(ctx context.Context, validator *validation.Validator, v string) error { + return c.ValidateString(ctx, validator, &v) +} diff --git a/message/messages.go b/message/messages.go index 0438f90..71f79cb 100644 --- a/message/messages.go +++ b/message/messages.go @@ -38,6 +38,9 @@ const ( BICNotAssociatedWithIBAN = "This Business Identifier Code (BIC) is not associated with IBAN {{ iban }}." InvalidISIN = "This value is not a valid International Securities Identification Number (ISIN)." InvalidISSN = "This value is not a valid ISSN." + InvalidISBN = "This value is neither a valid ISBN-10 nor a valid ISBN-13." + InvalidISBN10 = "This value is not a valid ISBN-10." + InvalidISBN13 = "This value is not a valid ISBN-13." InvalidCIDR = "This value is not a valid CIDR notation." CIDRNetmaskOutOfRange = "The value of the netmask should be between {{ min }} and {{ max }}." InvalidIP = "This is not a valid IP address." diff --git a/message/translations/english/messages.go b/message/translations/english/messages.go index 5eb9e72..c424a51 100644 --- a/message/translations/english/messages.go +++ b/message/translations/english/messages.go @@ -59,6 +59,9 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.BICNotAssociatedWithIBAN: catalog.String(message.BICNotAssociatedWithIBAN), message.InvalidISIN: catalog.String(message.InvalidISIN), message.InvalidISSN: catalog.String(message.InvalidISSN), + message.InvalidISBN: catalog.String(message.InvalidISBN), + message.InvalidISBN10: catalog.String(message.InvalidISBN10), + message.InvalidISBN13: catalog.String(message.InvalidISBN13), message.InvalidCIDR: catalog.String(message.InvalidCIDR), message.CIDRNetmaskOutOfRange: catalog.String(message.CIDRNetmaskOutOfRange), message.InvalidIP: catalog.String(message.InvalidIP), diff --git a/message/translations/russian/messages.go b/message/translations/russian/messages.go index aa17591..6b008e8 100644 --- a/message/translations/russian/messages.go +++ b/message/translations/russian/messages.go @@ -62,6 +62,9 @@ var Messages = map[language.Tag]map[string]catalog.Message{ message.BICNotAssociatedWithIBAN: catalog.String("Этот банковский идентификатор (BIC) не соответствует IBAN {{ iban }}."), message.InvalidISIN: catalog.String("Значение не является допустимым международным идентификационным номером ценной бумаги (ISIN)."), message.InvalidISSN: catalog.String("Значение не является допустимым ISSN."), + message.InvalidISBN: catalog.String("Значение не является допустимым ISBN-10 или ISBN-13."), + message.InvalidISBN10: catalog.String("Значение не является допустимым ISBN-10."), + message.InvalidISBN13: catalog.String("Значение не является допустимым ISBN-13."), message.InvalidCIDR: catalog.String("Значение не является допустимой записью CIDR."), message.CIDRNetmaskOutOfRange: catalog.String("Значение маски сети должно быть между {{ min }} и {{ max }}."), message.InvalidIP: catalog.String("Значение не является допустимым IP адресом."), diff --git a/test/constraints_identifiers_cases_test.go b/test/constraints_identifiers_cases_test.go index af04e11..1dbe6cb 100644 --- a/test/constraints_identifiers_cases_test.go +++ b/test/constraints_identifiers_cases_test.go @@ -12,6 +12,7 @@ var identifierConstraintsTestCases = mergeTestCases( ibanConstraintTestCases, bicConstraintTestCases, isinConstraintTestCases, + isbnConstraintTestCases, issnConstraintTestCases, luhnConstraintTestCases, ) @@ -297,6 +298,91 @@ var isinConstraintTestCases = []ConstraintValidationTestCase{ }, } +var isbnConstraintTestCases = []ConstraintValidationTestCase{ + { + name: "IsISBN passes on empty value", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsISBN(), + stringValue: stringValue(""), + assert: assertNoError, + }, + { + name: "IsISBN passes on valid ISBN-10", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("0-45122-5244"), + constraint: it.IsISBN(), + assert: assertNoError, + }, + { + name: "IsISBN passes on valid ISBN-13", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("978-0451225245"), + constraint: it.IsISBN(), + assert: assertNoError, + }, + { + name: "IsISBN violation on neither format", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("978272344228"), + constraint: it.IsISBN(), + assert: assertHasOneViolation(validation.ErrInvalidISBN, message.InvalidISBN), + }, + { + name: "IsISBN Only10 passes", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("0321812700"), + constraint: it.IsISBN().Only10(), + assert: assertNoError, + }, + { + name: "IsISBN Only10 violation uses ISBN-10 message", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("1234567890"), + constraint: it.IsISBN().Only10(), + assert: assertHasOneViolation(validation.ErrInvalidISBN10, message.InvalidISBN10), + }, + { + name: "IsISBN Only13 violation uses ISBN-13 message", + isApplicableFor: specificValueTypes(stringType), + stringValue: stringValue("2723442284"), + constraint: it.IsISBN().Only13(), + assert: assertHasOneViolation(validation.ErrInvalidISBN13, message.InvalidISBN13), + }, + { + name: "IsISBN violation with given error and message", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsISBN(). + WithError(ErrCustom). + WithMessage( + `Invalid value "{{ value }}" for {{ custom }}.`, + validation.TemplateParameter{Key: "{{ custom }}", Value: "parameter"}, + ), + stringValue: stringValue("bad-isbn"), + assert: assertHasOneViolation(ErrCustom, `Invalid value "bad-isbn" for parameter.`), + }, + { + name: "IsISBN passes when condition is false", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsISBN().When(false), + stringValue: stringValue("bad"), + assert: assertNoError, + }, + { + name: "IsISBN violation when condition is true", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsISBN().When(true), + stringValue: stringValue("bad"), + assert: assertHasOneViolation(validation.ErrInvalidISBN, message.InvalidISBN), + }, + { + name: "IsISBN passes when groups not match", + isApplicableFor: specificValueTypes(stringType), + constraint: it.IsISBN().WhenGroups(testGroup), + stringValue: stringValue("bad"), + assert: assertNoError, + }, +} + var issnConstraintTestCases = []ConstraintValidationTestCase{ { name: "IsISSN passes on empty value", diff --git a/validate/example_test.go b/validate/example_test.go index 23631fe..a64e77e 100644 --- a/validate/example_test.go +++ b/validate/example_test.go @@ -204,6 +204,16 @@ func ExampleISSN() { // invalid checksum } +func ExampleISBN() { + fmt.Println(validate.ISBN("978-0451225245")) + fmt.Println(validate.ISBN("978272344228")) + fmt.Println(validate.ISBN("1234567890", validate.ISBNOnly10())) + // Output: + // + // ISBN type not recognized + // ISBN checksum failed +} + func ExampleLUHN() { fmt.Println(validate.LUHN("79927398713")) fmt.Println(validate.LUHN("79927398710")) diff --git a/validate/isbn.go b/validate/isbn.go new file mode 100644 index 0000000..60e77b4 --- /dev/null +++ b/validate/isbn.go @@ -0,0 +1,155 @@ +package validate + +import ( + "errors" + "strings" +) + +// ISBN validation errors, aligned with Symfony\Component\Validator\Constraints\Isbn codes. +var ( + ErrISBNInvalidCharacters = errors.New("ISBN invalid characters") + ErrISBNTooShort = errors.New("ISBN too short") + ErrISBNTooLong = errors.New("ISBN too long") + ErrISBNChecksumFailed = errors.New("ISBN checksum failed") + ErrISBNTypeNotRecognized = errors.New("ISBN type not recognized") +) + +// ISBNType selects which ISBN format to validate, matching Symfony Isbn "type". +type ISBNType int + +const ( + // ISBNTypeAny accepts ISBN-10 or ISBN-13 (default). + ISBNTypeAny ISBNType = iota + // ISBNType10 accepts only ISBN-10 after removing hyphens. + ISBNType10 + // ISBNType13 accepts only ISBN-13 after removing hyphens. + ISBNType13 +) + +// ISBN validates whether the value is a valid ISBN-10 or ISBN-13. +// Hyphens (U+002D) are stripped; other characters are not allowed in the canonical form. +// Behavior is aligned with Symfony\Component\Validator\Constraints\Isbn and IsbnValidator. +// +// Empty string is considered valid (use [NotBlank] or similar to reject empty values). +// +// Possible errors: +// - [ErrISBNInvalidCharacters], [ErrISBNTooShort], [ErrISBNTooLong], [ErrISBNChecksumFailed] +// for the selected type; +// - [ErrISBNTypeNotRecognized] in "any" mode when the length is between 11 and 12 digits. +// +// See https://en.wikipedia.org/wiki/ISBN. +func ISBN(value string, options ...func(*ISBNOptions)) error { + if value == "" { + return nil + } + + opts := &ISBNOptions{typ: ISBNTypeAny} + for _, set := range options { + set(opts) + } + + canonical := strings.ReplaceAll(value, "-", "") + + switch opts.typ { + case ISBNType10: + return validateISBN10Body(canonical) + case ISBNType13: + return validateISBN13Body(canonical) + default: + return validateISBNAny(canonical) + } +} + +// ISBNOptions configures [ISBN]. +type ISBNOptions struct { + typ ISBNType +} + +// ISBNOnly10 restricts validation to ISBN-10 (Symfony Isbn::ISBN_10). +func ISBNOnly10() func(*ISBNOptions) { + return func(o *ISBNOptions) { + o.typ = ISBNType10 + } +} + +// ISBNOnly13 restricts validation to ISBN-13 (Symfony Isbn::ISBN_13). +func ISBNOnly13() func(*ISBNOptions) { + return func(o *ISBNOptions) { + o.typ = ISBNType13 + } +} + +func validateISBNAny(canonical string) error { + code := validateISBN10Body(canonical) + if errors.Is(code, ErrISBNTooLong) { + code = validateISBN13Body(canonical) + if errors.Is(code, ErrISBNTooShort) { + return ErrISBNTypeNotRecognized + } + } + return code +} + +func parseISBN10Char(c byte) (digit int, err error) { + switch { + case c == 'X': + return 10, nil + case c == 'x': + // Lowercase is invalid in Symfony (only uppercase X is accepted as check digit letter). + return 0, ErrISBNInvalidCharacters + case c >= '0' && c <= '9': + return int(c - '0'), nil + default: + return 0, ErrISBNInvalidCharacters + } +} + +// validateISBN10Body mirrors Symfony IsbnValidator::validateIsbn10 (error priority: +// invalid characters, too short/long, checksum). +func validateISBN10Body(isbn string) error { + var checkSum int + for i := 0; i < 10; i++ { + if i >= len(isbn) { + return ErrISBNTooShort + } + digit, err := parseISBN10Char(isbn[i]) + if err != nil { + return err + } + checkSum += digit * (10 - i) + } + if len(isbn) > 10 { + return ErrISBNTooLong + } + if checkSum%11 != 0 { + return ErrISBNChecksumFailed + } + return nil +} + +// validateISBN13Body mirrors Symfony IsbnValidator::validateIsbn13. +func validateISBN13Body(isbn string) error { + for i := 0; i < len(isbn); i++ { + if isbn[i] < '0' || isbn[i] > '9' { + return ErrISBNInvalidCharacters + } + } + switch { + case len(isbn) < 13: + return ErrISBNTooShort + case len(isbn) > 13: + return ErrISBNTooLong + } + + var checkSum int + for i := 0; i < 13; i += 2 { + checkSum += int(isbn[i] - '0') + } + for i := 1; i < 12; i += 2 { + checkSum += int(isbn[i]-'0') * 3 + } + if checkSum%10 != 0 { + return ErrISBNChecksumFailed + } + return nil +} diff --git a/validate/isbn_test.go b/validate/isbn_test.go new file mode 100644 index 0000000..e60748c --- /dev/null +++ b/validate/isbn_test.go @@ -0,0 +1,117 @@ +package validate_test + +import ( + "errors" + "testing" + + "github.com/muonsoft/validation/validate" + "github.com/stretchr/testify/assert" +) + +func TestISBN(t *testing.T) { + valid10 := []string{ + "2723442284", + "2723442276", + "2723455041", + "2070546810", + "2711858839", + "2756406767", + "2870971648", + "226623854X", + "2851806424", + "0321812700", + "0-45122-5244", + "0-4712-92311", + "0-9752298-0-X", + } + valid13 := []string{ + "978-2723442282", + "978-2723442275", + "978-2723455046", + "978-2070546817", + "978-2711858835", + "978-2756406763", + "978-2870971642", + "978-2266238540", + "978-2851806420", + "978-0321812704", + "978-0451225245", + "978-0471292319", + } + + for _, v := range append(valid10, valid13...) { + assert.NoError(t, validate.ISBN(v), v) + } + for _, v := range valid10 { + assert.NoError(t, validate.ISBN(v, validate.ISBNOnly10()), v) + } + for _, v := range valid13 { + assert.NoError(t, validate.ISBN(v, validate.ISBNOnly13()), v) + } + + t.Run("invalid ISBN-10", func(t *testing.T) { + cases := []struct { + value string + want error + }{ + {"27234422841", validate.ErrISBNTooLong}, + {"272344228", validate.ErrISBNTooShort}, + {"0-4712-9231", validate.ErrISBNTooShort}, + {"1234567890", validate.ErrISBNChecksumFailed}, + {"0987656789", validate.ErrISBNChecksumFailed}, + {"7-35622-5444", validate.ErrISBNChecksumFailed}, + {"0-4X19-92611", validate.ErrISBNChecksumFailed}, + {"0_45122_5244", validate.ErrISBNInvalidCharacters}, + {"2870#971#648", validate.ErrISBNInvalidCharacters}, + {"0-9752298-0-x", validate.ErrISBNInvalidCharacters}, + {"1A34567890", validate.ErrISBNInvalidCharacters}, + } + for _, tc := range cases { + err := validate.ISBN(tc.value, validate.ISBNOnly10()) + assert.ErrorIs(t, err, tc.want, tc.value) + } + }) + + t.Run("invalid ISBN-13", func(t *testing.T) { + cases := []struct { + value string + want error + }{ + {"978-27234422821", validate.ErrISBNTooLong}, + {"978-272344228", validate.ErrISBNTooShort}, + {"978-2723442-82", validate.ErrISBNTooShort}, + {"978-2723442281", validate.ErrISBNChecksumFailed}, + {"978-0321513774", validate.ErrISBNChecksumFailed}, + {"979-0431225385", validate.ErrISBNChecksumFailed}, + {"980-0474292319", validate.ErrISBNChecksumFailed}, + {"0-4X19-92619812", validate.ErrISBNInvalidCharacters}, + {"978_2723442282", validate.ErrISBNInvalidCharacters}, + {"978#2723442282", validate.ErrISBNInvalidCharacters}, + {"978-272C442282", validate.ErrISBNInvalidCharacters}, + } + for _, tc := range cases { + err := validate.ISBN(tc.value, validate.ISBNOnly13()) + assert.ErrorIs(t, err, tc.want, tc.value) + } + }) + + t.Run("explicit type length mismatch", func(t *testing.T) { + assert.ErrorIs(t, validate.ISBN("978-2723442282", validate.ISBNOnly10()), validate.ErrISBNTooLong) + assert.ErrorIs(t, validate.ISBN("2723442284", validate.ISBNOnly13()), validate.ErrISBNTooShort) + }) + + t.Run("any mode type not recognized", func(t *testing.T) { + // 11 digits: too long for ISBN-10, too short for ISBN-13 + assert.ErrorIs(t, validate.ISBN("978272344228"), validate.ErrISBNTypeNotRecognized) + assert.ErrorIs(t, validate.ISBN("97827234422821"), validate.ErrISBNTooLong) + }) + + t.Run("empty is valid", func(t *testing.T) { + assert.NoError(t, validate.ISBN("")) + }) +} + +func TestISBN_wrapsErrors(t *testing.T) { + err := validate.ISBN("bad", validate.ISBNOnly10()) + assert.True(t, errors.Is(err, validate.ErrISBNInvalidCharacters)) +}